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 +3 -2
- package/bin/init.mjs +75 -43
- package/bin/skills.mjs +196 -0
- package/package.json +2 -2
- package/{skills → site-skills}/caracms-adopt/SKILL.md +11 -10
- package/{skills → site-skills}/caracms-tina/SKILL.md +1 -1
- package/src/cli/validate.ts +4 -0
- package/src/index.ts +8 -0
- /package/{skills → site-skills}/caracms-adopt/look-same.mjs +0 -0
- /package/{skills → site-skills}/caracms-blocks/SKILL.md +0 -0
- /package/{skills → site-skills}/caracms-content/SKILL.md +0 -0
- /package/{skills → site-skills}/caracms-hosting/SKILL.md +0 -0
- /package/{skills → site-skills}/caracms-tina/field-kinds.md +0 -0
- /package/{skills → site-skills}/caracms-tina/tina-to-caracms.mjs +0 -0
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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' |
|
|
566
|
-
* 'unlink' | 'schemas', file, before?, after?,
|
|
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
|
-
|
|
614
|
-
|
|
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
|
|
670
|
+
// Skills (Decision 12; copies since 0.9.0, skills.mjs).
|
|
666
671
|
const skills = skillNames();
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
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 '
|
|
897
|
-
return `
|
|
930
|
+
case 'remove':
|
|
931
|
+
return ` remove ${change.file} (${change.why})`;
|
|
898
932
|
case 'unlink':
|
|
899
|
-
return ` unlink ${change.file} (
|
|
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 '
|
|
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.
|
|
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), `
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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,
|
|
91
|
-
skills
|
|
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
|
|
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):
|
package/src/cli/validate.ts
CHANGED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|