caracms 0.8.3 → 0.9.1

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
 
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 } = {}) {
@@ -610,8 +611,12 @@ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
610
611
  if (!site.setUp) add('caracms.config.ts', mode ? withServices(template('caracms.config.ts.txt'), mode).text : template('caracms.config.ts.txt'));
611
612
  else if (mode && mode !== field) edit('caracms.config.ts', site.caracmsConfigText, withServices(site.caracmsConfigText, mode).text);
612
613
  else if (mode) done.push(`caracms.config.ts (services: '${mode}')`);
613
- add('src/components/caracms/Page.astro', template('Page.astro.txt'));
614
- add('src/components/caracms/TextBlock.astro', template('TextBlock.astro.txt'));
614
+ // The starting page component and block, for a new site only: a set-up site's config names its own,
615
+ // and may have deleted these (am-rebrand's port did; 0.9.0's init added TextBlock.astro back).
616
+ if (!site.setUp) {
617
+ add('src/components/caracms/Page.astro', template('Page.astro.txt'));
618
+ add('src/components/caracms/TextBlock.astro', template('TextBlock.astro.txt'));
619
+ }
615
620
  add(`${posix(site.srcDir)}/pages/[...path].astro`, template('catch-all.astro.txt'));
616
621
  const health = pageRoutes(root, site.srcDir).find((r) => !r.dynamic && r.url === '/health');
617
622
  const healthFile = `${posix(site.srcDir)}/pages/health.ts`;
@@ -662,24 +667,13 @@ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
662
667
  notes.push(railwayNeeds(railwayFile, mode));
663
668
  } else done.push(railwayFile);
664
669
 
665
- // Skills (Decision 12), linked from the installed package.
670
+ // Skills (Decision 12; copies since 0.9.0, skills.mjs).
666
671
  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
- }
672
+ {
673
+ const s = skillsPlan(root);
674
+ changes.push(...s.changes);
675
+ notes.push(...s.notes);
676
+ done.push(...s.done);
683
677
  }
684
678
 
685
679
  // The CLAUDE.md block (Decision 12): into the link's target when CLAUDE.md is a link.
@@ -692,12 +686,59 @@ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
692
686
  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
687
  else edit(file, before, after, `${file} (the caracms block)`);
694
688
  }
689
+ // pnpm 12.11+ asked to approve the skills caracms shipped before 0.9.0; approving wrote `permissions:
690
+ // caracms: skills: true`, which fails an install that runs before the repo is copied in (Railway's,
691
+ // Urban Return 2026-10-28). The package no longer ships skills pnpm links, so it's taken back out.
692
+ {
693
+ const before = read(join(root, 'pnpm-workspace.yaml'));
694
+ const after = before === null ? null : withoutSkillsApproval(before);
695
+ 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.`);
696
+ else if (after !== null && after !== before) {
697
+ changes.push({ kind: 'edit', file: 'pnpm-workspace.yaml', before, after });
698
+ 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.`);
699
+ }
700
+ }
695
701
  // MEC trial item 1: what Tina left behind, untracked (tinaOf).
696
702
  const { leftover } = tinaOf(site);
697
703
  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
704
  return { changes, notes, done, hostVariables: !railway && !hasRailway };
699
705
  }
700
706
 
707
+ /**
708
+ * pnpm-workspace.yaml's text without the approval of caracms's agent skills (`permissions:` →
709
+ * `caracms:` → `skills: true`), emptied parents and all; the same text when there's none.
710
+ * Undefined when there is one in a shape these lines can't follow (a flow mapping, say).
711
+ */
712
+ export function withoutSkillsApproval(text) {
713
+ const lines = text.split('\n');
714
+ const indent = (l) => /^ */.exec(l)[0].length;
715
+ const blank = (l) => /^\s*(#.*)?$/.test(l);
716
+ // The lines under `lines[at]`: everything indented deeper, up to the next line that isn't.
717
+ const childrenOf = (at) => {
718
+ const out = [];
719
+ for (let i = at + 1; i < lines.length; i++) {
720
+ if (blank(lines[i])) continue;
721
+ if (indent(lines[i]) <= indent(lines[at])) break;
722
+ out.push(i);
723
+ }
724
+ return out;
725
+ };
726
+ const top = lines.findIndex((l) => /^permissions:\s*(#.*)?$/.test(l));
727
+ if (top === -1) return /^permissions:/m.test(text) && /caracms/.test(text) ? undefined : text;
728
+ const under = childrenOf(top);
729
+ const pkg = under.find((i) => /^\s+(['"]?)caracms\1:/.test(lines[i]));
730
+ if (pkg === undefined) return text;
731
+ const rest = lines[pkg].replace(/^\s+(['"]?)caracms\1:/, '').replace(/#.*$/, '').trim();
732
+ if (rest) return /skills\s*:\s*true/.test(rest) ? undefined : text;
733
+ const own = childrenOf(pkg);
734
+ const skills = own.find((i) => /^\s+skills:\s*true\s*(#.*)?$/.test(lines[i]));
735
+ if (skills === undefined) return /skills\s*:\s*true/.test(own.map((i) => lines[i]).join('\n')) ? undefined : text;
736
+ const drop = new Set([skills]);
737
+ if (own.length === 1) drop.add(pkg);
738
+ if (own.length === 1 && under.every((i) => i === pkg || own.includes(i))) drop.add(top);
739
+ return lines.filter((_, i) => !drop.has(i)).join('\n');
740
+ }
741
+
701
742
  /** `object` with `key` added, in alphabetical order when the object already is. */
702
743
  function withKey(object, key, value) {
703
744
  if (Object.hasOwn(object, key)) return object;
@@ -810,13 +851,6 @@ function railwayNeeds(file, mode = null) {
810
851
  ].join('\n');
811
852
  }
812
853
 
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
854
  /**
821
855
  * @param {string[]} skills
822
856
  * @param {string | null} [mode] the config's `services`; null: not set
@@ -828,7 +862,7 @@ export function claudeBlock(skills, mode = null) {
828
862
  '',
829
863
  MODE_LINES[mode ?? 'none'],
830
864
  '',
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:",
865
+ "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
866
  '',
833
867
  ...skills.map((name) => `- \`${name}\`${SKILL_BLURBS[name] ? `: ${SKILL_BLURBS[name]}.` : ''}`),
834
868
  ...(skills.length ? [''] : []),
@@ -893,10 +927,10 @@ function describe(change) {
893
927
  return ` add ${change.file}`;
894
928
  case 'edit':
895
929
  return ` edit ${change.file}`;
896
- case 'link':
897
- return ` link ${change.file} → ${change.target}`;
930
+ case 'remove':
931
+ return ` remove ${change.file} (${change.why})`;
898
932
  case 'unlink':
899
- return ` unlink ${change.file} (that skill is no longer in the package)`;
933
+ return ` unlink ${change.file} (${change.why})`;
900
934
  case 'schemas':
901
935
  return ` ${change.before === null ? 'add ' : change.after === null ? 'remove' : 'edit '} ${change.file}${change.generated ? ' (generated: caracms schemas)' : ''}`;
902
936
  }
@@ -911,7 +945,7 @@ function report({ changes, notes, done, hostVariables, mode, where, site }, { wr
911
945
  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
946
  parts.push(changes.map(describe).join('\n'));
913
947
  if (!write) {
914
- const diffs = changes.filter((c) => (c.kind === 'add' || c.kind === 'edit' || (c.kind === 'schemas' && !c.generated && c.after !== null)));
948
+ const diffs = changes.filter((c) => (((c.kind === 'add' || c.kind === 'edit') && !c.skill) || (c.kind === 'schemas' && !c.generated && c.after !== null)));
915
949
  if (diffs.length) parts.push(diffs.map((c) => diff(c.file, c.before ?? null, c.after)).join('\n\n'));
916
950
  }
917
951
  }
@@ -1008,13 +1042,11 @@ function apply(root, changes) {
1008
1042
  mkdirSync(dirname(path), { recursive: true });
1009
1043
  writeFileSync(path, c.after);
1010
1044
  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;
1045
+ case 'remove':
1016
1046
  case 'unlink':
1017
1047
  rmSync(path, { force: true });
1048
+ // A skill folder left empty goes too (skills.mjs: a skill the package no longer has).
1049
+ 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
1050
  break;
1019
1051
  // 'schemas' is written by the schemas command, after everything else.
1020
1052
  }
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.3",
3
+ "version": "0.9.1",
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
 
@@ -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.
File without changes