caracms 0.8.2 → 0.9.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
@@ -48,7 +48,7 @@ npm install
48
48
 
49
49
  `init` adds the plumbing (the Node adapter, the integration, a minimal `caracms.config.ts`
50
50
  and page component, the one catch-all route that serves every document with an address,
51
- the schemas) and links caracms's
51
+ the schemas) and copies caracms's
52
52
  skills into `.claude/skills/`. It doesn't touch the site's pages. Bringing them in is the
53
53
  job of the `caracms-adopt` skill, one page at a time.
54
54
 
@@ -74,7 +74,8 @@ Run from the site's root:
74
74
  - `caracms start`: serve the built site (Hosting, below).
75
75
 
76
76
  The skills (`caracms-content`, `caracms-blocks`, `caracms-adopt`, `caracms-tina` and
77
- `caracms-hosting`, in `skills/`) say how the rest works, matching the installed version.
77
+ `caracms-hosting`, in `site-skills/`) say how the rest works. `init` copies them into the site, and brings
78
+ them up to date after an upgrade; `astro dev` and `caracms validate` say when they're behind.
78
79
 
79
80
  ## Collections and addresses
80
81
 
@@ -369,7 +370,7 @@ export default defineSite({
369
370
  (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in
370
371
  app.caracms.com." (the build fails; a running server's `/admin` answers 503 with it, and
371
372
  the public site keeps serving). It never falls back to the self-hosted variables.
372
- `astro dev` without a key is local mode, as always.
373
+ `astro dev` is local mode, with or without the key (see [working locally](#working-locally)).
373
374
  - **`'self-hosted'`**: the site's own variables, as [the variables](#the-variables) list
374
375
  them. A `CARACMS_CLOUD_KEY` beside them is ignored the same way.
375
376
  - **Not set** (a site from before caracms 0.8.0): the key decides, and setting it beside
@@ -538,11 +539,13 @@ has been replaced.
538
539
  laptop, a colleague's, a client's developer, a staging deploy. Each gets **its own key**
539
540
  for the site, labelled ("Rich's laptop", "staging") in app.caracms.com, so revoking one
540
541
  never touches the live site. Leave `CARACMS_CLOUD_URL` unset: images render from
541
- `img.caracms.cloud` and the library lists the site's real folder. A cloud key turns
542
- `astro dev` into live mode on the site's real repo, as the App's variables do. Uploads
542
+ `img.caracms.cloud` and the library lists the site's real folder. `astro dev` stays in
543
+ local mode, editing the files on disk: live mode on a laptop never points at the site's
544
+ real repo, so the key alone never turns it on (the App's variables still do). Uploads
543
545
  from a laptop land in the real folder and count towards its storage, which is harmless:
544
546
  nothing is overwritten or deleted, and nothing is live until it's published.
545
- `CARACMS_CLOUD_URL` is only for working on CaraCMS Cloud itself, against its dev deployment.
547
+ `CARACMS_CLOUD_URL` is only for working on CaraCMS Cloud itself, against its dev deployment;
548
+ with it, `astro dev` is live on that deployment's throwaway repos.
546
549
 
547
550
  ### Behind Cloudflare
548
551
 
package/bin/init.mjs CHANGED
@@ -19,20 +19,23 @@
19
19
  // left as they are on a site that already has the file (runs-anywhere Decision 12).
20
20
  // Otherwise init writes no host file and prints the variables any host needs
21
21
  // (HOST_VARIABLES), on the dry run and on `--write`.
22
- // - Run again, it re-links the skills, refreshes the CLAUDE.md block and the schemas, and
22
+ // - Run again, it refreshes the skills (copies, skills.mjs), the CLAUDE.md block and the schemas, and
23
23
  // reports the rest as already done.
24
24
  //
25
- // Plain JavaScript importing only Node built-ins and media.mjs (and, for the schemas and the
25
+ // Plain JavaScript importing only Node built-ins, media.mjs and skills.mjs (and, for the schemas and the
26
26
  // config's `services`, the site's own Vite through site-runner.mjs), so it runs on Node
27
27
  // 22.12 with no flags.
28
28
 
29
29
  import { execFileSync } from 'node:child_process';
30
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from 'node:fs';
31
31
  import { createRequire } from 'node:module';
32
32
  import { createInterface } from 'node:readline/promises';
33
33
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
34
34
  import { fileURLToPath } from 'node:url';
35
35
  import { DEFAULT_CLOUD_URL, OWN_MEDIA_NAMES, parseDotenv, REPLACED_NAMES } from './media.mjs';
36
+ import { skillNames, skillsPlan } from './skills.mjs';
37
+
38
+ export { skillNames };
36
39
 
37
40
  const packageRoot = fileURLToPath(new URL('..', import.meta.url));
38
41
  const templates = fileURLToPath(new URL('./templates/', import.meta.url));
@@ -86,8 +89,6 @@ export const VERSIONS = { node: '^11.1.7', railway: '^3.16.0' };
86
89
 
87
90
  export const MARK_START = '<!-- caracms:start -->';
88
91
  export const MARK_END = '<!-- caracms:end -->';
89
- /** Where a site's skills link to: the installed package (Decision 12). */
90
- const SKILLS_LINK_BASE = '../../node_modules/caracms/skills/';
91
92
  /** The URLs the package reserves (src/slug.ts → addressProblem). */
92
93
  const RESERVED = ['/admin', '/_caracms', '/files'];
93
94
  const ROUTE_EXTENSIONS = ['.astro', '.md', '.mdx', '.markdown', '.html', '.ts', '.js', '.mjs', '.mts'];
@@ -562,8 +563,8 @@ export async function askMode({ input = process.stdin, output = process.stdout }
562
563
  // ── The changes ─────────────────────────────────────────────────────────────────
563
564
 
564
565
  /**
565
- * Everything init would do, as a list of changes ({ kind: 'add' | 'edit' | 'link' |
566
- * 'unlink' | 'schemas', file, before?, after?, target? }), notes (printed, nothing written),
566
+ * Everything init would do, as a list of changes ({ kind: 'add' | 'edit' |
567
+ * 'remove' | 'unlink' | 'schemas', file, before?, after?, why?, skill? }), notes (printed, nothing written),
567
568
  * and what's already done.
568
569
  */
569
570
  function changesOf(site, { railway = false, mode = null, field = null } = {}) {
@@ -662,24 +663,13 @@ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
662
663
  notes.push(railwayNeeds(railwayFile, mode));
663
664
  } else done.push(railwayFile);
664
665
 
665
- // Skills (Decision 12), linked from the installed package.
666
+ // Skills (Decision 12; copies since 0.9.0, skills.mjs).
666
667
  const skills = skillNames();
667
- const skillsDir = join(root, '.claude/skills');
668
- for (const name of skills) {
669
- const file = `.claude/skills/${name}`;
670
- const target = `${SKILLS_LINK_BASE}${name}`;
671
- const st = lstat(join(root, file));
672
- if (!st) changes.push({ kind: 'link', file, target });
673
- else if (st.isSymbolicLink() && readlinkSync(join(root, file)) === target) done.push(file);
674
- else if (st.isSymbolicLink() && readlinkSync(join(root, file)).startsWith(SKILLS_LINK_BASE)) changes.push({ kind: 'link', file, target, replace: true });
675
- else notes.push(`${file} is the site's own, so init doesn't link caracms's ${name} skill over it.`);
676
- }
677
- if (isDir(skillsDir)) {
678
- for (const name of readdirSync(skillsDir).sort()) {
679
- const path = join(skillsDir, name);
680
- if (skills.includes(name) || !lstat(path)?.isSymbolicLink()) continue;
681
- if (readlinkSync(path).startsWith(SKILLS_LINK_BASE)) changes.push({ kind: 'unlink', file: `.claude/skills/${name}` });
682
- }
668
+ {
669
+ const s = skillsPlan(root);
670
+ changes.push(...s.changes);
671
+ notes.push(...s.notes);
672
+ done.push(...s.done);
683
673
  }
684
674
 
685
675
  // The CLAUDE.md block (Decision 12): into the link's target when CLAUDE.md is a link.
@@ -692,12 +682,59 @@ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
692
682
  if (after === null) notes.push(`${file} has a ${MARK_START} line with no ${MARK_END} after it, so init can't tell where its block ends and leaves the file alone. Replace the old block with this one by hand, then run init again:\n${block.split('\n').map((l) => ` ${l}`).join('\n')}`);
693
683
  else edit(file, before, after, `${file} (the caracms block)`);
694
684
  }
685
+ // pnpm 12.11+ asked to approve the skills caracms shipped before 0.9.0; approving wrote `permissions:
686
+ // caracms: skills: true`, which fails an install that runs before the repo is copied in (Railway's,
687
+ // Urban Return 2026-10-28). The package no longer ships skills pnpm links, so it's taken back out.
688
+ {
689
+ const before = read(join(root, 'pnpm-workspace.yaml'));
690
+ const after = before === null ? null : withoutSkillsApproval(before);
691
+ if (after === undefined) notes.push(`pnpm-workspace.yaml approves caracms's agent skills in a shape init can't edit. Take the approval out by hand (the \`skills: true\` under \`permissions:\` → \`caracms:\`): it fails an install that runs before the repo is copied in, Railway's for one, and init copies the skills in itself.`);
692
+ else if (after !== null && after !== before) {
693
+ changes.push({ kind: 'edit', file: 'pnpm-workspace.yaml', before, after });
694
+ notes.push(`pnpm-workspace.yaml approved caracms's agent skills (\`pnpm approve\`), so init takes that out: the approval fails an install that runs before the repo is copied in, Railway's for one, and init copies the skills in itself. pnpm won't ask about caracms again.`);
695
+ }
696
+ }
695
697
  // MEC trial item 1: what Tina left behind, untracked (tinaOf).
696
698
  const { leftover } = tinaOf(site);
697
699
  if (leftover) notes.push(`${leftover.length ? leftover.join(', ') : 'tina/'} ${leftover.length > 1 ? 'are' : 'is'} left over from Tina (nothing under tina/ is tracked): delete ${leftover.length > 1 ? 'them' : 'it'}.`);
698
700
  return { changes, notes, done, hostVariables: !railway && !hasRailway };
699
701
  }
700
702
 
703
+ /**
704
+ * pnpm-workspace.yaml's text without the approval of caracms's agent skills (`permissions:` →
705
+ * `caracms:` → `skills: true`), emptied parents and all; the same text when there's none.
706
+ * Undefined when there is one in a shape these lines can't follow (a flow mapping, say).
707
+ */
708
+ export function withoutSkillsApproval(text) {
709
+ const lines = text.split('\n');
710
+ const indent = (l) => /^ */.exec(l)[0].length;
711
+ const blank = (l) => /^\s*(#.*)?$/.test(l);
712
+ // The lines under `lines[at]`: everything indented deeper, up to the next line that isn't.
713
+ const childrenOf = (at) => {
714
+ const out = [];
715
+ for (let i = at + 1; i < lines.length; i++) {
716
+ if (blank(lines[i])) continue;
717
+ if (indent(lines[i]) <= indent(lines[at])) break;
718
+ out.push(i);
719
+ }
720
+ return out;
721
+ };
722
+ const top = lines.findIndex((l) => /^permissions:\s*(#.*)?$/.test(l));
723
+ if (top === -1) return /^permissions:/m.test(text) && /caracms/.test(text) ? undefined : text;
724
+ const under = childrenOf(top);
725
+ const pkg = under.find((i) => /^\s+(['"]?)caracms\1:/.test(lines[i]));
726
+ if (pkg === undefined) return text;
727
+ const rest = lines[pkg].replace(/^\s+(['"]?)caracms\1:/, '').replace(/#.*$/, '').trim();
728
+ if (rest) return /skills\s*:\s*true/.test(rest) ? undefined : text;
729
+ const own = childrenOf(pkg);
730
+ const skills = own.find((i) => /^\s+skills:\s*true\s*(#.*)?$/.test(lines[i]));
731
+ if (skills === undefined) return /skills\s*:\s*true/.test(own.map((i) => lines[i]).join('\n')) ? undefined : text;
732
+ const drop = new Set([skills]);
733
+ if (own.length === 1) drop.add(pkg);
734
+ if (own.length === 1 && under.every((i) => i === pkg || own.includes(i))) drop.add(top);
735
+ return lines.filter((_, i) => !drop.has(i)).join('\n');
736
+ }
737
+
701
738
  /** `object` with `key` added, in alphabetical order when the object already is. */
702
739
  function withKey(object, key, value) {
703
740
  if (Object.hasOwn(object, key)) return object;
@@ -810,13 +847,6 @@ function railwayNeeds(file, mode = null) {
810
847
  ].join('\n');
811
848
  }
812
849
 
813
- /** The package's skills: its `skills/<name>/` folders. */
814
- export function skillNames() {
815
- const dir = join(packageRoot, 'skills');
816
- if (!isDir(dir)) return [];
817
- return readdirSync(dir).filter((n) => !n.startsWith('.') && isDir(join(dir, n))).sort();
818
- }
819
-
820
850
  /**
821
851
  * @param {string[]} skills
822
852
  * @param {string | null} [mode] the config's `services`; null: not set
@@ -828,7 +858,7 @@ export function claudeBlock(skills, mode = null) {
828
858
  '',
829
859
  MODE_LINES[mode ?? 'none'],
830
860
  '',
831
- "This site's content is edited with caracms (the `caracms` package). Its skills are linked from the installed package into `.claude/skills/`, so they always match the installed version:",
861
+ "This site's content is edited with caracms (the `caracms` package). Its skills are copied from the installed package into `.claude/skills/` by `caracms init`, which brings them up to date after an upgrade (`astro dev` and `caracms validate` say when they're behind):",
832
862
  '',
833
863
  ...skills.map((name) => `- \`${name}\`${SKILL_BLURBS[name] ? `: ${SKILL_BLURBS[name]}.` : ''}`),
834
864
  ...(skills.length ? [''] : []),
@@ -893,10 +923,10 @@ function describe(change) {
893
923
  return ` add ${change.file}`;
894
924
  case 'edit':
895
925
  return ` edit ${change.file}`;
896
- case 'link':
897
- return ` link ${change.file} → ${change.target}`;
926
+ case 'remove':
927
+ return ` remove ${change.file} (${change.why})`;
898
928
  case 'unlink':
899
- return ` unlink ${change.file} (that skill is no longer in the package)`;
929
+ return ` unlink ${change.file} (${change.why})`;
900
930
  case 'schemas':
901
931
  return ` ${change.before === null ? 'add ' : change.after === null ? 'remove' : 'edit '} ${change.file}${change.generated ? ' (generated: caracms schemas)' : ''}`;
902
932
  }
@@ -911,7 +941,7 @@ function report({ changes, notes, done, hostVariables, mode, where, site }, { wr
911
941
  parts.push(write ? 'caracms init: done. It changed:' : 'caracms init: the change it would make. Nothing is written: `caracms init --write` applies exactly this.');
912
942
  parts.push(changes.map(describe).join('\n'));
913
943
  if (!write) {
914
- const diffs = changes.filter((c) => (c.kind === 'add' || c.kind === 'edit' || (c.kind === 'schemas' && !c.generated && c.after !== null)));
944
+ const diffs = changes.filter((c) => (((c.kind === 'add' || c.kind === 'edit') && !c.skill) || (c.kind === 'schemas' && !c.generated && c.after !== null)));
915
945
  if (diffs.length) parts.push(diffs.map((c) => diff(c.file, c.before ?? null, c.after)).join('\n\n'));
916
946
  }
917
947
  }
@@ -1008,13 +1038,11 @@ function apply(root, changes) {
1008
1038
  mkdirSync(dirname(path), { recursive: true });
1009
1039
  writeFileSync(path, c.after);
1010
1040
  break;
1011
- case 'link':
1012
- mkdirSync(dirname(path), { recursive: true });
1013
- if (c.replace) rmSync(path, { force: true });
1014
- symlinkSync(c.target, path);
1015
- break;
1041
+ case 'remove':
1016
1042
  case 'unlink':
1017
1043
  rmSync(path, { force: true });
1044
+ // A skill folder left empty goes too (skills.mjs: a skill the package no longer has).
1045
+ for (let dir = dirname(path); c.kind === 'remove' && dir.startsWith(join(root, '.claude/skills/')) && readdirSync(dir).length === 0; dir = dirname(dir)) rmSync(dir, { recursive: true });
1018
1046
  break;
1019
1047
  // 'schemas' is written by the schemas command, after everything else.
1020
1048
  }
package/bin/skills.mjs ADDED
@@ -0,0 +1,196 @@
1
+ // The skills caracms puts in a site: the package's `site-skills/<name>/` folders, copied into the
2
+ // site's `.claude/skills/<name>/` by `caracms init`, with a stamp of what was copied
3
+ // (`.claude/skills/.caracms.json`: each file's hash). Copies, not links (0.9.0): pnpm 12.11+ asks
4
+ // to approve any package that ships `skills/<name>/SKILL.md`, and an approval fails an install
5
+ // that runs before the repo is copied in (Railway's). So the package keeps them in `site-skills/`,
6
+ // which pnpm doesn't look in, and a site's copies work on a fresh clone with nothing installed.
7
+ //
8
+ // The stamp is how init tells a copy it may refresh from one someone edited: a file whose hash
9
+ // is still the stamp's is caracms's, and is brought up to date; one that isn't is left alone,
10
+ // with a note. `astro dev` and `caracms validate` say when the copies are behind (skillsWarning).
11
+ //
12
+ // Plain JavaScript importing only Node built-ins: init.mjs, validate and the integration share it.
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import { lstatSync, readdirSync, readFileSync, readlinkSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const packageRoot = fileURLToPath(new URL('..', import.meta.url));
20
+ const source = join(packageRoot, 'site-skills');
21
+ export const SKILLS_DIR = '.claude/skills';
22
+ export const STAMP = `${SKILLS_DIR}/.caracms.json`;
23
+ /** Where 0.8 and earlier linked a site's skills to. */
24
+ export const OLD_LINK_BASE = '../../node_modules/caracms/skills/';
25
+
26
+ const hash = (text) => createHash('sha256').update(text).digest('hex');
27
+ const lstat = (path) => lstatSync(path, { throwIfNoEntry: false }) ?? null;
28
+ const read = (path) => {
29
+ try {
30
+ return readFileSync(path, 'utf8');
31
+ } catch {
32
+ return null;
33
+ }
34
+ };
35
+
36
+ /** The package's skills: its `site-skills/<name>/` folders. */
37
+ export function skillNames() {
38
+ if (!lstat(source)?.isDirectory()) return [];
39
+ return readdirSync(source, { withFileTypes: true })
40
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
41
+ .map((d) => d.name)
42
+ .sort();
43
+ }
44
+
45
+ /** The files under `dir`, as `/`-separated paths relative to it, sorted. */
46
+ function filesUnder(dir, prefix = '') {
47
+ const out = [];
48
+ for (const d of readdirSync(dir, { withFileTypes: true })) {
49
+ if (d.name.startsWith('.')) continue;
50
+ const rel = `${prefix}${d.name}`;
51
+ if (d.isDirectory()) out.push(...filesUnder(join(dir, d.name), `${rel}/`));
52
+ else if (d.isFile()) out.push(rel);
53
+ }
54
+ return out.sort();
55
+ }
56
+
57
+ /** The package's skill files: `<name>/<file>` → text. */
58
+ function packageFiles() {
59
+ const out = new Map();
60
+ for (const name of skillNames()) for (const file of filesUnder(join(source, name))) out.set(`${name}/${file}`, readFileSync(join(source, name, file), 'utf8'));
61
+ return out;
62
+ }
63
+
64
+ /** The stamp's hashes (`<name>/<file>` → hash), or null when there's no stamp or it doesn't parse. */
65
+ function stampOf(root) {
66
+ try {
67
+ const files = JSON.parse(read(join(root, STAMP)) ?? '').files;
68
+ return files && typeof files === 'object' ? files : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * What init does to bring a site's `.claude/skills/` to the package's skills, as init's changes
76
+ * (`add`, `edit`, `remove` and `unlink`; a skill file's carry `skill: true`, so a dry run lists
77
+ * them without diffs), notes and what's already done.
78
+ * @param {string} root
79
+ */
80
+ export function skillsPlan(root) {
81
+ const changes = [];
82
+ const notes = [];
83
+ const done = [];
84
+ const wanted = packageFiles();
85
+ const stamp = stampOf(root) ?? {};
86
+ const next = {};
87
+ const at = (rel) => `${SKILLS_DIR}/${rel}`;
88
+ const mine = (rel, text) => stamp[rel] !== undefined && hash(text) === stamp[rel];
89
+
90
+ for (const name of skillNames()) {
91
+ const dir = at(name);
92
+ const st = lstat(join(root, dir));
93
+ const files = [...wanted.keys()].filter((rel) => rel.startsWith(`${name}/`));
94
+ const fresh = () => {
95
+ for (const rel of files) {
96
+ changes.push({ kind: 'add', file: at(rel), after: wanted.get(rel), skill: true });
97
+ next[rel] = hash(wanted.get(rel));
98
+ }
99
+ };
100
+ if (st?.isSymbolicLink()) {
101
+ if (!readlinkSync(join(root, dir)).startsWith(OLD_LINK_BASE)) {
102
+ notes.push(`${dir} is the site's own (a link elsewhere), so init doesn't copy caracms's ${name} skill over it.`);
103
+ continue;
104
+ }
105
+ // 0.8 and earlier: a link into node_modules, now a copy.
106
+ changes.push({ kind: 'unlink', file: dir, why: 'a link into node_modules, now a copy' });
107
+ fresh();
108
+ continue;
109
+ }
110
+ if (!st) {
111
+ fresh();
112
+ continue;
113
+ }
114
+ // Caracms's when the stamp names it, or when every file is caracms's word for word (a lost stamp).
115
+ const ours = Object.keys(stamp).some((rel) => rel.startsWith(`${name}/`)) || (st.isDirectory() && files.every((rel) => read(join(root, at(rel))) === wanted.get(rel)));
116
+ if (!st.isDirectory() || !ours) {
117
+ notes.push(`${dir} is the site's own, so init doesn't copy caracms's ${name} skill over it.`);
118
+ continue;
119
+ }
120
+ let current = true;
121
+ for (const rel of files) {
122
+ const want = wanted.get(rel);
123
+ const have = read(join(root, at(rel)));
124
+ if (have === want) next[rel] = hash(want);
125
+ else if (have === null || mine(rel, have)) {
126
+ changes.push({ kind: have === null ? 'add' : 'edit', file: at(rel), before: have, after: want, skill: true });
127
+ next[rel] = hash(want);
128
+ current = false;
129
+ } else {
130
+ notes.push(`${at(rel)} has been edited here, so init leaves it as it is. caracms's own is node_modules/caracms/site-skills/${rel}: bring your changes across, or delete the file and run init again.`);
131
+ if (stamp[rel] !== undefined) next[rel] = stamp[rel];
132
+ current = false;
133
+ }
134
+ }
135
+ // Files caracms copied that the skill no longer has.
136
+ for (const rel of Object.keys(stamp).filter((r) => r.startsWith(`${name}/`) && !wanted.has(r))) {
137
+ const have = read(join(root, at(rel)));
138
+ if (have === null) continue;
139
+ if (mine(rel, have)) changes.push({ kind: 'remove', file: at(rel), why: 'no longer in the skill' });
140
+ else {
141
+ notes.push(`${at(rel)} is no longer in caracms's ${name} skill, but it has been edited here, so init leaves it.`);
142
+ next[rel] = stamp[rel];
143
+ }
144
+ current = false;
145
+ }
146
+ if (current) done.push(dir);
147
+ }
148
+
149
+ // Skills the package no longer has: their copies (when unedited) and their 0.8 links.
150
+ const names = new Set(skillNames());
151
+ for (const rel of Object.keys(stamp).filter((r) => !names.has(r.split('/')[0]))) {
152
+ const have = read(join(root, at(rel)));
153
+ if (have === null) continue;
154
+ if (mine(rel, have)) changes.push({ kind: 'remove', file: at(rel), why: 'that skill is no longer in the package' });
155
+ else {
156
+ notes.push(`${at(rel)} is from a skill caracms no longer has, but it has been edited here, so init leaves it.`);
157
+ next[rel] = stamp[rel];
158
+ }
159
+ }
160
+ const dirPath = join(root, SKILLS_DIR);
161
+ if (lstat(dirPath)?.isDirectory()) {
162
+ for (const name of readdirSync(dirPath).sort()) {
163
+ if (names.has(name) || !lstat(join(dirPath, name))?.isSymbolicLink()) continue;
164
+ if (readlinkSync(join(dirPath, name)).startsWith(OLD_LINK_BASE)) changes.push({ kind: 'unlink', file: at(name), why: 'that skill is no longer in the package' });
165
+ // pnpm's own links, from an approval of 0.8's skills (gitignored, so only on that machine): each skill twice.
166
+ else if (name.startsWith('pnpm-caracms-')) changes.push({ kind: 'unlink', file: at(name), why: "pnpm's link from an approval, a second copy of a skill" });
167
+ }
168
+ }
169
+
170
+ const before = read(join(root, STAMP));
171
+ const after = `${JSON.stringify({ about: "caracms's skills as `caracms init` copied them: it refreshes a file whose hash is still this one, and leaves an edited one alone.", files: next }, null, 2)}\n`;
172
+ if (before !== after && (changes.length || Object.keys(next).length)) changes.push({ kind: before === null ? 'add' : 'edit', file: STAMP, before, after, skill: true });
173
+ return { changes, notes, done };
174
+ }
175
+
176
+ /**
177
+ * One sentence when the site's copies are behind the installed package (what `astro dev` and
178
+ * `caracms validate` warn), else null. Only for a site that has caracms's skills already (a
179
+ * stamp, or 0.8's links): one that never ran init, or took them out, gets nothing.
180
+ * @param {string} root
181
+ * @returns {string | null}
182
+ */
183
+ export function skillsWarning(root) {
184
+ const linked = (() => {
185
+ const dirPath = join(root, SKILLS_DIR);
186
+ if (!lstat(dirPath)?.isDirectory()) return false;
187
+ return readdirSync(dirPath).some((n) => lstat(join(dirPath, n))?.isSymbolicLink() && readlinkSync(join(dirPath, n)).startsWith(OLD_LINK_BASE));
188
+ })();
189
+ if (!linked && stampOf(root) === null) return null;
190
+ const behind = skillsPlan(root).changes.some((c) => c.file !== STAMP);
191
+ if (!behind) return null;
192
+ const version = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
193
+ return linked
194
+ ? `${SKILLS_DIR} links caracms's skills from node_modules, and caracms ${version} copies them in instead: run \`npx caracms init --write\` and commit the result.`
195
+ : `${SKILLS_DIR} is behind the skills of the installed caracms (${version}): run \`npx caracms init --write\` and commit the result.`;
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "files": [
27
27
  "src",
28
28
  "bin",
29
- "skills",
29
+ "site-skills",
30
30
  "README.md",
31
31
  "LICENSE"
32
32
  ],
@@ -33,8 +33,8 @@ Start from a clean git checkout (untracked files are fine). The order is:
33
33
  **`npx caracms init --cloud`** (or `--self-hosted`) without `--write` is a dry run: it
34
34
  writes nothing and prints
35
35
 
36
- - one line per file: `add` (a new file), `edit` (an existing one), `link` / `unlink` (a
37
- skill link in `.claude/skills/`);
36
+ - one line per file: `add` (a new file), `edit` (an existing one), `remove` / `unlink` (a
37
+ skill file or link in `.claude/skills/` that goes);
38
38
  - a diff of each;
39
39
  - `Already done: …` on a site partly set up;
40
40
  - `Notes:`, things it can't do itself, with the lines to add by hand (an `astro.config` it
@@ -56,11 +56,12 @@ to tracked files: usually the install of step 1, not yet committed.
56
56
  Read the diff. Then **`npx caracms init --cloud --write`** (or `--self-hosted --write`),
57
57
  install again (`npm install` / `pnpm install`), and commit that as one change.
58
58
 
59
- pnpm 12.11 and later may print `Agent skills awaiting approval: caracms` after the install.
60
- That's pnpm's own gate: it links the skills a dependency ships into `.claude/skills/` only
61
- once you approve them with `pnpm approve` (`pnpm permissions` lists what's waiting).
62
- `init` has already linked caracms's skills itself, so there's nothing to do; leave it
63
- unapproved, and don't approve it in passing (`pnpm approve` also reviews build scripts).
59
+ caracms's skills are copied into `.claude/skills/` by `init`, as real files committed with
60
+ the site, so they work on a fresh clone with nothing installed. pnpm doesn't ask to approve
61
+ them (since 0.9.0 the package keeps them where pnpm doesn't look). A site that approved
62
+ 0.8's has `permissions: caracms: skills: true` in `pnpm-workspace.yaml`, which fails an
63
+ install that runs before the repo is copied in (Railway's, `ERR_PNPM_NO_AGENT_SKILLS_DIR`):
64
+ `init --write` takes it out.
64
65
 
65
66
  `init` added:
66
67
 
@@ -76,7 +77,7 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
76
77
  `devToolbar: { enabled: false }`;
77
78
  - `package.json`: `@astrojs/node`, `"start": "caracms start"`;
78
79
  `.gitignore`: `.caracms/`; the tsconfig's `allowImportingTsExtensions`;
79
- - the skill links and a block in `CLAUDE.md`;
80
+ - the skills, copied into `.claude/skills/` with a stamp (`.caracms.json`) of what it copied, and a block in `CLAUDE.md`;
80
81
  - with `--railway` only, `.railway/railway.ts` (when the site had none) and `railway`
81
82
  (dev). Without it, `init` prints the variables any host needs instead. Where and how
82
83
  the site runs (Railway, Render, Fly, a VPS) is `caracms-hosting` → section 2.
@@ -87,8 +88,8 @@ the output moves to `dist/client/` and `dist/server/`, and `npm start` is now
87
88
  (its own built-site checks, a script) needs the new paths: the pages are in
88
89
  `dist/client/`.
89
90
 
90
- Re-run `init` after updating caracms: it reads the mode from the config, re-links the
91
- skills and refreshes the CLAUDE.md block and the schemas. On a set-up site with nothing
91
+ Re-run `init` after updating caracms: it reads the mode from the config, refreshes the
92
+ skills (leaving any file edited here alone, with a note), the CLAUDE.md block and the schemas. On a set-up site with nothing
92
93
  to do it says
93
94
  `caracms init: nothing to do. caracms is set up here.`
94
95
 
@@ -115,7 +116,7 @@ is the exception (section 4).
115
116
  **Images need credentials** in the site's `.env` (never committed). On CaraCMS Cloud
116
117
  that's `CARACMS_CLOUD_KEY` alone: a key labelled for this laptop, made in
117
118
  app.caracms.com (`caracms-hosting` → section 1); it shows, imports and uploads images,
118
- and makes `astro dev` live on the site's real repo. Self-hosted, it's the media
119
+ and `astro dev` still edits the files on disk. Self-hosted, it's the media
119
120
  variables: `CARACMS_MEDIA_URL` (imgproxy's origin), `IMGPROXY_KEY`, `IMGPROXY_SALT` and
120
121
  `S3_BUCKET` to show images, plus `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID` and
121
122
  `S3_SECRET_ACCESS_KEY` to import and upload them. Without them `astro dev` draws a
@@ -81,8 +81,9 @@ npx caracms init --cloud --write # writes services: 'cloud' and the rest
81
81
  **One key per place the site runs**, each labelled in app.caracms.com: "Railway" for
82
82
  the host, "Rich's laptop" for a laptop's `.env`, "staging" for a staging deploy.
83
83
  Revoking one never touches the others. Leave `CARACMS_CLOUD_URL` unset (it's only for
84
- working on CaraCMS Cloud itself). On a laptop a cloud key makes `astro dev` live on the
85
- site's real repo and folder; without one, `astro dev` edits content on disk as always.
84
+ working on CaraCMS Cloud itself). On a laptop `astro dev` edits content on disk, with or
85
+ without a key; the key shows, imports and uploads images in the site's real folder. Only
86
+ the key with `CARACMS_CLOUD_URL` (the cloud's dev deployment) makes `astro dev` live.
86
87
 
87
88
  **A production build needs the key.** With `services: 'cloud'` and no
88
89
  `CARACMS_CLOUD_KEY`, `astro build` (and `pnpm build` on a laptop, which is a production
@@ -91,7 +91,7 @@ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after e
91
91
 
92
92
  ## 2. The conversion script
93
93
 
94
- Copy `node_modules/caracms/skills/caracms-tina/tina-to-caracms.mjs` into the site's own
94
+ Copy `.claude/skills/caracms-tina/tina-to-caracms.mjs` into the site's own
95
95
  `scripts/`. The site's copy is the one that runs; it's committed with the site.
96
96
 
97
97
  **Adapt only the table under `ADAPT HERE`** (the script's header says what each entry is):
@@ -41,6 +41,7 @@ import { sameAddress } from '../slug.ts';
41
41
  import { hiddenDocuments, hiddenPages } from '../links.ts';
42
42
  import { runSchemas, schemaFileChanges, schemasReport } from './schemas.ts';
43
43
  import { jsonFilesUnder, loadSite } from './site.ts';
44
+ import { skillsWarning } from '../../bin/skills.mjs';
44
45
 
45
46
  export interface Finding {
46
47
  /** Relative to the site's root. */
@@ -475,6 +476,9 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
475
476
  message: change.text === null ? 'The config no longer makes this schema. `caracms schemas` removes it.' : change.before === null ? 'This schema file is missing. `caracms schemas` writes it.' : "This schema file is out of date with caracms.config.ts. `caracms schemas` rewrites it.",
476
477
  });
477
478
  }
479
+ // The skills init copied in (bin/skills.mjs), when the installed caracms has newer ones. A warning: old docs never fail a pull request.
480
+ const skills = skillsWarning(site.root);
481
+ if (skills) report.warnings.push({ file: '.claude/skills', path: '', message: skills });
478
482
  return report;
479
483
  }
480
484
 
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ import { resolveSite, SITE_SETTINGS_FILE, type ResolvedSite, type SiteConfig } f
58
58
  import { lockupCss } from './lockup-css.ts';
59
59
  import { shareBuildReads } from './read.ts';
60
60
  import { writeSchemaFiles } from './cli/schemas.ts';
61
+ import { skillsWarning } from '../bin/skills.mjs';
61
62
  import { configFile } from './cli/site.ts';
62
63
  import { pagesRoutes } from './code-routes-disk.ts';
63
64
  import { codeRoutesFrom } from './code-routes.ts';
@@ -523,6 +524,13 @@ export default function caracms(config: SiteConfig): AstroIntegration {
523
524
  } catch (error) {
524
525
  logger.warn(`couldn't write schemas/: ${(error as Error).message}`);
525
526
  }
527
+ // The skills init copied in (bin/skills.mjs): one line when the installed caracms has newer ones.
528
+ try {
529
+ const skills = skillsWarning(site.root);
530
+ if (skills) logger.warn(skills);
531
+ } catch {
532
+ // A warning about docs never stops the dev server.
533
+ }
526
534
  // A content change re-runs every route's getStaticPaths, as Astro does for its own content
527
535
  // collections (the same event, to the same server environments). Never a browser reload:
528
536
  // the editor writes these files.
package/src/live/mode.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Which mode the editor runs in, read from the environment (M4 Decision 1).
3
3
  *
4
- * - **Local mode**: `astro dev` with no GitHub credentials (`CARACMS_CLOUD_KEY`,
5
- * `GITHUB_APP_ID` or `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk.
4
+ * - **Local mode**: `astro dev` with no GitHub credentials (`GITHUB_APP_ID` or
5
+ * `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk. `CARACMS_CLOUD_KEY` alone stays
6
+ * local (caracms-cloud: live mode on a laptop never points at a client's repo); the key
7
+ * is still the images', clips' and forms' credential there. Only the key with
8
+ * `CARACMS_CLOUD_URL` (the cloud's dev deployment, throwaway repos only) is live in dev.
6
9
  * - **Live mode**: whenever a credential is configured, and **always on a production
7
10
  * server**. It needs one GitHub credential (a fine-grained token, or the App's
8
11
  * `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`; runs-anywhere Decisions 3–4), the repo
@@ -208,7 +211,9 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
208
211
  // Never GITHUB_TOKEN (Decision 4): the gh CLI and GitHub Actions export it.
209
212
  const ghToken = get('CARACMS_GITHUB_TOKEN');
210
213
  const cloudKey = get('CARACMS_CLOUD_KEY');
211
- if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken && !cloudKey) return { kind: 'local' };
214
+ // A laptop's key is the production cloud's, so the site's real repo: dev stays local unless CARACMS_CLOUD_URL names another cloud.
215
+ const devCloud = cloudKey && get('CARACMS_CLOUD_URL');
216
+ if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken && !devCloud) return { kind: 'local' };
212
217
 
213
218
  const missing: string[] = [];
214
219
  const problems: string[] = [];
File without changes