agents-md-kit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +148 -2
- package/bin/agent-kit.js +6 -0
- package/core/files/.claude/settings.json +16 -0
- package/core/files/.claude/skills/agent-memory-protocol/SKILL.md +117 -0
- package/core/files/.claude/skills/api-design-rest/SKILL.md +126 -0
- package/core/files/.claude/skills/backend-architecture-principles/SKILL.md +116 -0
- package/core/files/.claude/skills/backend-security/SKILL.md +117 -0
- package/core/files/.claude/skills/changelog-and-versioning/SKILL.md +169 -0
- package/core/files/.claude/skills/database-best-practices/SKILL.md +104 -0
- package/core/files/.claude/skills/git-commit-and-workflow/SKILL.md +140 -0
- package/core/files/.claude/skills/project-management-workflow/SKILL.md +181 -0
- package/core/files/CLAUDE.md +1 -0
- package/core/files/MEMORY.md +77 -0
- package/core/files/docs/adr/0000-template.md +21 -0
- package/core/fragments/agents-core.md +61 -0
- package/core/manifest.txt +21 -0
- package/docs/monorepo.md +72 -0
- package/package.json +50 -4
- package/packs/github/files/.github/ISSUE_TEMPLATE/bug_report.md +22 -0
- package/packs/github/files/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/packs/github/files/.github/pull_request_template.md +24 -0
- package/packs/github/files/.github/workflows/pr-title.yml +37 -0
- package/packs/github/manifest.txt +6 -0
- package/packs/web/files/.claude/skills/html-syntax-and-semantics/SKILL.md +127 -0
- package/packs/web/files/.claude/skills/seo-best-practices/SKILL.md +158 -0
- package/packs/web/fragments/agents-web.md +11 -0
- package/packs/web/manifest.txt +5 -0
- package/presets/node-backend/docs/node-backend.md +60 -0
- package/presets/node-backend/files/.editorconfig +12 -0
- package/presets/node-backend/files/.husky/commit-msg +1 -0
- package/presets/node-backend/files/.husky/pre-commit +1 -0
- package/presets/node-backend/files/.lintstagedrc.json +4 -0
- package/presets/node-backend/files/.prettierrc.json +7 -0
- package/presets/node-backend/files/.release-please-manifest.json +3 -0
- package/presets/node-backend/files/CHANGELOG.md +5 -0
- package/presets/node-backend/files/commitlint.config.mjs +12 -0
- package/presets/node-backend/files/eslint.config.mjs +38 -0
- package/presets/node-backend/files/release-please-config.json +25 -0
- package/presets/node-backend/fragments/agents-node-backend-github.md +7 -0
- package/presets/node-backend/fragments/agents-node-backend.md +13 -0
- package/presets/node-backend/fragments/gitignore.txt +19 -0
- package/presets/node-backend/fragments/prettierignore.txt +16 -0
- package/presets/node-backend/github/dependabot.yml +21 -0
- package/presets/node-backend/github/workflows/ci.yml +38 -0
- package/presets/node-backend/github/workflows/release-please.yml +21 -0
- package/presets/node-backend/manifest.txt +30 -0
- package/presets/python/docs/python.md +66 -0
- package/presets/python/files/.editorconfig +15 -0
- package/presets/python/files/.pre-commit-config.yaml +32 -0
- package/presets/python/files/.release-please-manifest.json +3 -0
- package/presets/python/files/CHANGELOG.md +5 -0
- package/presets/python/files/mypy.ini +5 -0
- package/presets/python/files/pytest.ini +6 -0
- package/presets/python/files/release-please-config.json +25 -0
- package/presets/python/files/ruff.toml +17 -0
- package/presets/python/fragments/agents-python-github.md +7 -0
- package/presets/python/fragments/agents-python.md +14 -0
- package/presets/python/fragments/gitignore.txt +25 -0
- package/presets/python/github/dependabot.yml +18 -0
- package/presets/python/github/workflows/ci.yml +37 -0
- package/presets/python/github/workflows/release-please.yml +21 -0
- package/presets/python/manifest.txt +25 -0
- package/presets/react-typescript/docs/react-typescript.md +71 -0
- package/presets/react-typescript/files/.claude/skills/react-principles/SKILL.md +264 -0
- package/presets/react-typescript/files/.editorconfig +12 -0
- package/presets/react-typescript/files/.husky/commit-msg +1 -0
- package/presets/react-typescript/files/.husky/pre-commit +1 -0
- package/presets/react-typescript/files/.lintstagedrc.json +4 -0
- package/presets/react-typescript/files/.prettierrc.json +7 -0
- package/presets/react-typescript/files/.release-please-manifest.json +3 -0
- package/presets/react-typescript/files/CHANGELOG.md +5 -0
- package/presets/react-typescript/files/commitlint.config.mjs +12 -0
- package/presets/react-typescript/files/eslint.config.mjs +55 -0
- package/presets/react-typescript/files/release-please-config.json +25 -0
- package/presets/react-typescript/fragments/agents-react-typescript-github.md +7 -0
- package/presets/react-typescript/fragments/agents-react-typescript.md +17 -0
- package/presets/react-typescript/fragments/gitignore.txt +22 -0
- package/presets/react-typescript/fragments/prettierignore.txt +17 -0
- package/presets/react-typescript/github/dependabot.yml +21 -0
- package/presets/react-typescript/github/workflows/ci.yml +38 -0
- package/presets/react-typescript/github/workflows/release-please.yml +21 -0
- package/presets/react-typescript/manifest.txt +32 -0
- package/src/blocks.js +132 -0
- package/src/cli.js +159 -0
- package/src/fsutil.js +123 -0
- package/src/install.js +223 -0
- package/src/manifest.js +187 -0
- package/src/validate.js +324 -0
package/src/manifest.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Line-based manifest parser and unit discovery. No dependencies.
|
|
2
|
+
//
|
|
3
|
+
// copy <path> [requires=<pack>] [from=<unit-relative source>]
|
|
4
|
+
// copy-once <path> [requires=<pack>] [from=<unit-relative source>]
|
|
5
|
+
// block <id> <dest> <fragment> [style=html|hash] [requires=<pack>]
|
|
6
|
+
// implies <pack> # selecting this unit also selects that pack (not allowed in core)
|
|
7
|
+
//
|
|
8
|
+
// `#` starts a comment (whole line or trailing). Fields split on whitespace,
|
|
9
|
+
// so paths cannot contain spaces. A first-line comment `# description: ...`
|
|
10
|
+
// is used by `agent-kit list`.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export class ManifestError extends Error {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{ kind: 'copy' | 'copy-once', dest: string, source: string, requires: string | null, line: number }} CopyEntry
|
|
19
|
+
* @typedef {{ kind: 'block', id: string, dest: string, fragment: string, source: string, style: 'html' | 'hash', requires: string | null, line: number }} BlockEntry
|
|
20
|
+
* @typedef {CopyEntry | BlockEntry} Entry
|
|
21
|
+
* @typedef {'core' | 'pack' | 'preset'} UnitType
|
|
22
|
+
* @typedef {{ name: string, type: UnitType, dir: string, entries: Entry[], implies: string[], runs: string[], description: string }} Unit
|
|
23
|
+
* @typedef {{ core: Unit, packs: Map<string, Unit>, presets: Map<string, Unit> }} Units
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
27
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check that a manifest path is a safe, relative, forward-slash path.
|
|
31
|
+
* Rejected regardless of host OS: absolute POSIX paths, Windows drive and UNC
|
|
32
|
+
* paths, backslashes, `.`/`..`/empty segments, NUL bytes.
|
|
33
|
+
* Returns an error message, or null when the path is safe.
|
|
34
|
+
*/
|
|
35
|
+
export function checkRelPath(/** @type {unknown} */ p) {
|
|
36
|
+
if (typeof p !== 'string' || p.length === 0) return 'empty path';
|
|
37
|
+
if (p.includes('\0')) return 'path contains a NUL byte';
|
|
38
|
+
if (p.includes('\\')) return 'path contains a backslash (use forward slashes)';
|
|
39
|
+
if (p.startsWith('/')) return 'absolute path';
|
|
40
|
+
if (/^[A-Za-z]:/.test(p)) return 'absolute or drive-relative Windows path';
|
|
41
|
+
if (p.startsWith('~')) return 'path starts with ~';
|
|
42
|
+
for (const seg of p.split('/')) {
|
|
43
|
+
if (seg === '') return 'path has an empty segment';
|
|
44
|
+
if (seg === '.' || seg === '..') return `path contains "${seg}"`;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseOptions(/** @type {string[]} */ tokens, /** @type {string[]} */ allowed, /** @type {string} */ where) {
|
|
50
|
+
/** @type {Record<string, string>} */
|
|
51
|
+
const opts = {};
|
|
52
|
+
for (const tok of tokens) {
|
|
53
|
+
const eq = tok.indexOf('=');
|
|
54
|
+
if (eq <= 0) throw new ManifestError(`${where}: unexpected field "${tok}"`);
|
|
55
|
+
const key = tok.slice(0, eq);
|
|
56
|
+
const value = tok.slice(eq + 1);
|
|
57
|
+
if (!allowed.includes(key)) {
|
|
58
|
+
throw new ManifestError(`${where}: unknown option "${key}" (allowed: ${allowed.join(', ')})`);
|
|
59
|
+
}
|
|
60
|
+
if (key in opts) throw new ManifestError(`${where}: option "${key}" given twice`);
|
|
61
|
+
if (value === '') throw new ManifestError(`${where}: option "${key}" has no value`);
|
|
62
|
+
opts[key] = value;
|
|
63
|
+
}
|
|
64
|
+
if (opts.requires !== undefined && !NAME_RE.test(opts.requires)) {
|
|
65
|
+
throw new ManifestError(`${where}: invalid requires value "${opts.requires}"`);
|
|
66
|
+
}
|
|
67
|
+
if (opts.style !== undefined && opts.style !== 'html' && opts.style !== 'hash') {
|
|
68
|
+
throw new ManifestError(`${where}: style must be "html" or "hash", got "${opts.style}"`);
|
|
69
|
+
}
|
|
70
|
+
if (opts.from !== undefined) {
|
|
71
|
+
const err = checkRelPath(opts.from);
|
|
72
|
+
if (err) throw new ManifestError(`${where}: unsafe from path "${opts.from}": ${err}`);
|
|
73
|
+
}
|
|
74
|
+
return opts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safePath(/** @type {string} */ p, /** @type {string} */ where, /** @type {string} */ what) {
|
|
78
|
+
const err = checkRelPath(p);
|
|
79
|
+
if (err) throw new ManifestError(`${where}: unsafe ${what} "${p}": ${err}`);
|
|
80
|
+
return p;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parse manifest text into entries. Throws ManifestError on the first problem. */
|
|
84
|
+
export function parseManifest(/** @type {string} */ text, label = 'manifest') {
|
|
85
|
+
/** @type {Entry[]} */
|
|
86
|
+
const entries = [];
|
|
87
|
+
/** @type {string[]} */
|
|
88
|
+
const implies = [];
|
|
89
|
+
/** @type {string[]} */
|
|
90
|
+
const runs = []; // `# run: <command>` lines: commands the user runs (files we never touch)
|
|
91
|
+
let description = '';
|
|
92
|
+
const lines = text.split(/\r?\n/);
|
|
93
|
+
lines.forEach((raw, i) => {
|
|
94
|
+
const where = `${label}:${i + 1}`;
|
|
95
|
+
const descMatch = /^#\s*description:\s*(.*)$/.exec(raw.trim());
|
|
96
|
+
if (descMatch && !description) description = descMatch[1].trim();
|
|
97
|
+
const runMatch = /^#\s*run:\s*(.+)$/.exec(raw.trim());
|
|
98
|
+
if (runMatch) runs.push(runMatch[1].trim());
|
|
99
|
+
const hash = raw.indexOf('#');
|
|
100
|
+
const line = (hash >= 0 ? raw.slice(0, hash) : raw).trim();
|
|
101
|
+
if (!line) return;
|
|
102
|
+
const [kind, ...rest] = line.split(/\s+/);
|
|
103
|
+
if (kind === 'copy' || kind === 'copy-once') {
|
|
104
|
+
if (rest.length < 1) throw new ManifestError(`${where}: ${kind} needs a path`);
|
|
105
|
+
const [p, ...optTokens] = rest;
|
|
106
|
+
const opts = parseOptions(optTokens, ['requires', 'from'], where);
|
|
107
|
+
entries.push({
|
|
108
|
+
kind,
|
|
109
|
+
dest: safePath(p, where, 'path'),
|
|
110
|
+
source: opts.from ?? `files/${p}`,
|
|
111
|
+
requires: opts.requires ?? null,
|
|
112
|
+
line: i + 1,
|
|
113
|
+
});
|
|
114
|
+
} else if (kind === 'block') {
|
|
115
|
+
if (rest.length < 3) throw new ManifestError(`${where}: block needs <id> <dest> <fragment>`);
|
|
116
|
+
const [id, dest, fragment, ...optTokens] = rest;
|
|
117
|
+
if (!ID_RE.test(id)) throw new ManifestError(`${where}: invalid block id "${id}" (use a-z, 0-9, -)`);
|
|
118
|
+
const opts = parseOptions(optTokens, ['style', 'requires'], where);
|
|
119
|
+
entries.push({
|
|
120
|
+
kind,
|
|
121
|
+
id,
|
|
122
|
+
dest: safePath(dest, where, 'dest'),
|
|
123
|
+
fragment: safePath(fragment, where, 'fragment'),
|
|
124
|
+
source: `fragments/${fragment}`,
|
|
125
|
+
style: /** @type {'html' | 'hash'} */ (opts.style ?? 'html'),
|
|
126
|
+
requires: opts.requires ?? null,
|
|
127
|
+
line: i + 1,
|
|
128
|
+
});
|
|
129
|
+
} else if (kind === 'implies') {
|
|
130
|
+
if (rest.length !== 1) throw new ManifestError(`${where}: implies needs exactly one pack name`);
|
|
131
|
+
if (!NAME_RE.test(rest[0])) throw new ManifestError(`${where}: invalid pack name "${rest[0]}"`);
|
|
132
|
+
if (implies.includes(rest[0])) throw new ManifestError(`${where}: pack "${rest[0]}" implied twice`);
|
|
133
|
+
implies.push(rest[0]);
|
|
134
|
+
} else {
|
|
135
|
+
throw new ManifestError(`${where}: unknown entry type "${kind}" (expected copy, copy-once, block or implies)`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return { entries, implies, runs, description };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function listDirs(/** @type {string} */ dir) {
|
|
142
|
+
try {
|
|
143
|
+
return fs
|
|
144
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
145
|
+
.filter((d) => d.isDirectory())
|
|
146
|
+
.map((d) => d.name)
|
|
147
|
+
.sort();
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') return [];
|
|
150
|
+
throw e;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Discover units under kitRoot: `core`, `packs/<name>`, `presets/<name>`.
|
|
156
|
+
* A folder counts as a unit only if it has a manifest.txt.
|
|
157
|
+
* Returns { core, packs: Map, presets: Map } of { name, type, dir, entries, description }.
|
|
158
|
+
*/
|
|
159
|
+
export function loadUnits(/** @type {string} */ kitRoot) {
|
|
160
|
+
/** @returns {Unit} */
|
|
161
|
+
const load = (/** @type {string} */ name, /** @type {UnitType} */ type, /** @type {string} */ dir) => {
|
|
162
|
+
const file = path.join(dir, 'manifest.txt');
|
|
163
|
+
const label = path.relative(kitRoot, file).split(path.sep).join('/');
|
|
164
|
+
const { entries, implies, runs, description } = parseManifest(fs.readFileSync(file, 'utf8'), label);
|
|
165
|
+
if (type === 'core' && implies.length) throw new ManifestError(`${label}: core cannot use implies`);
|
|
166
|
+
return { name, type, dir, entries, implies, runs, description };
|
|
167
|
+
};
|
|
168
|
+
const coreDir = path.join(kitRoot, 'core');
|
|
169
|
+
if (!fs.existsSync(path.join(coreDir, 'manifest.txt'))) {
|
|
170
|
+
throw new ManifestError(`no core/manifest.txt under ${kitRoot}`);
|
|
171
|
+
}
|
|
172
|
+
/** @type {Units} */
|
|
173
|
+
const result = { core: load('core', 'core', coreDir), packs: new Map(), presets: new Map() };
|
|
174
|
+
for (const [type, key] of /** @type {const} */ ([
|
|
175
|
+
['pack', 'packs'],
|
|
176
|
+
['preset', 'presets'],
|
|
177
|
+
])) {
|
|
178
|
+
const base = path.join(kitRoot, key);
|
|
179
|
+
for (const name of listDirs(base)) {
|
|
180
|
+
const dir = path.join(base, name);
|
|
181
|
+
if (!fs.existsSync(path.join(dir, 'manifest.txt'))) continue;
|
|
182
|
+
if (!NAME_RE.test(name)) throw new ManifestError(`invalid ${type} folder name "${name}"`);
|
|
183
|
+
result[key].set(name, load(name, type, dir));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Validation for an installed repo (default) and for this kit's own template (--template).
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { loadUnits } from './manifest.js';
|
|
6
|
+
import { findBlocks } from './blocks.js';
|
|
7
|
+
import { readTargetFile, walkFiles } from './fsutil.js';
|
|
8
|
+
|
|
9
|
+
const SKILLS_DIR = '.claude/skills';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {import('./manifest.js').Units} Units
|
|
13
|
+
* @typedef {{ known: Set<string>, available: Set<string> }} RefContext
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Parse simple `key: value` YAML frontmatter. Returns null if there is none. */
|
|
17
|
+
export function parseFrontmatter(/** @type {string} */ text) {
|
|
18
|
+
const lines = text.replace(/^/, '').split(/\r?\n/);
|
|
19
|
+
if (lines[0].trim() !== '---') return null;
|
|
20
|
+
/** @type {Record<string, string>} */
|
|
21
|
+
const data = {};
|
|
22
|
+
for (let i = 1; i < lines.length; i++) {
|
|
23
|
+
if (lines[i].trim() === '---') return data;
|
|
24
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
|
|
25
|
+
if (m) data[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
26
|
+
}
|
|
27
|
+
return null; // never closed
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkSkillFile(/** @type {string} */ text, /** @type {string} */ folder, /** @type {string} */ label, /** @type {string[]} */ errors) {
|
|
31
|
+
const fm = parseFrontmatter(text);
|
|
32
|
+
if (!fm) return errors.push(`${label}: missing or unterminated frontmatter`);
|
|
33
|
+
if (fm.name !== folder) errors.push(`${label}: frontmatter name "${fm.name ?? ''}" must equal folder name "${folder}"`);
|
|
34
|
+
if (!fm.description) errors.push(`${label}: frontmatter description is missing or empty`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Backtick code spans outside fenced code blocks. */
|
|
38
|
+
export function codeSpans(/** @type {string} */ text) {
|
|
39
|
+
const withoutFences = text.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '');
|
|
40
|
+
/** @type {string[]} */
|
|
41
|
+
const spans = [];
|
|
42
|
+
for (const m of withoutFences.matchAll(/`([^`\n]+)`/g)) spans.push(m[1].trim());
|
|
43
|
+
return spans;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check backtick references to skills. known = every skill name the kit or repo knows. */
|
|
47
|
+
function checkRefs(/** @type {string} */ text, /** @type {string} */ label, /** @type {RefContext} */ { known, available }, /** @type {string[]} */ errors) {
|
|
48
|
+
for (const span of codeSpans(text)) {
|
|
49
|
+
if (span.endsWith('.md')) {
|
|
50
|
+
const base = path.posix.basename(span.slice(0, -3));
|
|
51
|
+
if (known.has(base) && !span.includes('/')) {
|
|
52
|
+
errors.push(`${label}: skill reference \`${span}\` must not end in .md (use \`${base}\`)`);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (known.has(span) && !available.has(span)) {
|
|
57
|
+
errors.push(`${label}: reference to skill \`${span}\` does not resolve (not installed)`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Skill names listed in index tables: markdown tables whose header has a
|
|
64
|
+
* "Skill" column. Only text inside the given regions is considered.
|
|
65
|
+
*/
|
|
66
|
+
export function indexedSkills(/** @type {string} */ text) {
|
|
67
|
+
/** @type {string[]} */
|
|
68
|
+
const names = [];
|
|
69
|
+
let col = -1;
|
|
70
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
71
|
+
const line = raw.trim();
|
|
72
|
+
if (!line.startsWith('|')) {
|
|
73
|
+
col = -1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const cells = line.replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
|
|
77
|
+
if (col === -1) {
|
|
78
|
+
col = cells.findIndex((c) => c.toLowerCase() === 'skill');
|
|
79
|
+
if (col === -1) col = -2; // a table without a Skill column
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (col < 0 || /^:?-+:?$/.test(cells[0] ?? '')) continue;
|
|
83
|
+
const m = /^`([^`]+)`$/.exec(cells[col] ?? '');
|
|
84
|
+
if (m) names.push(m[1]);
|
|
85
|
+
}
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function blockRegions(/** @type {string} */ text) {
|
|
90
|
+
const { blocks } = findBlocks(text);
|
|
91
|
+
return blocks.map((b) => text.slice(b.start, b.end)).join('\n\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function kitSkillNames(/** @type {Units} */ units) {
|
|
95
|
+
/** @type {Map<string, string>} skill name -> unit name */
|
|
96
|
+
const out = new Map();
|
|
97
|
+
for (const u of [units.core, ...units.packs.values(), ...units.presets.values()]) {
|
|
98
|
+
for (const e of u.entries) {
|
|
99
|
+
const m = /^\.claude\/skills\/([^/]+)\/SKILL\.md$/.exec(e.dest);
|
|
100
|
+
if (m && e.kind !== 'block') out.set(m[1], u.name);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lightYamlCheck(/** @type {string} */ text, /** @type {string} */ label, /** @type {string[]} */ errors) {
|
|
107
|
+
if (!text.trim()) return errors.push(`${label}: YAML file is empty`);
|
|
108
|
+
const lines = text.split(/\r?\n/);
|
|
109
|
+
lines.forEach((l, i) => {
|
|
110
|
+
if (/^[ ]*\t/.test(l)) errors.push(`${label}:${i + 1}: tab used for indentation in YAML`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkJson(/** @type {string} */ text, /** @type {string} */ label, /** @type {string[]} */ errors) {
|
|
115
|
+
try {
|
|
116
|
+
JSON.parse(text.replace(/^/, ''));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
errors.push(`${label}: invalid JSON (${/** @type {Error} */ (e).message})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Validate an installed repository. */
|
|
123
|
+
export function validateRepo(
|
|
124
|
+
/** @type {{ target: string, kitRoot: string, platform?: string }} */ { target, kitRoot, platform = process.platform },
|
|
125
|
+
) {
|
|
126
|
+
/** @type {string[]} */
|
|
127
|
+
const errors = [];
|
|
128
|
+
/** @type {string[]} */
|
|
129
|
+
const warnings = [];
|
|
130
|
+
const notes = ['YAML files get a light check only (non-empty, no tab indentation), not a full parse.'];
|
|
131
|
+
const root = fs.realpathSync(target);
|
|
132
|
+
const units = loadUnits(kitRoot);
|
|
133
|
+
const read = (/** @type {string} */ rel) => {
|
|
134
|
+
try {
|
|
135
|
+
const buf = readTargetFile(root, rel);
|
|
136
|
+
return buf === null ? null : buf.toString('utf8');
|
|
137
|
+
} catch (e) {
|
|
138
|
+
errors.push(`${rel}: ${/** @type {Error} */ (e).message}`);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Skills
|
|
144
|
+
const skillsAbs = path.join(root, ...SKILLS_DIR.split('/'));
|
|
145
|
+
/** @type {Set<string>} */
|
|
146
|
+
const installed = new Set();
|
|
147
|
+
const skillTexts = [];
|
|
148
|
+
if (fs.existsSync(skillsAbs)) {
|
|
149
|
+
for (const d of fs.readdirSync(skillsAbs, { withFileTypes: true })) {
|
|
150
|
+
if (d.isSymbolicLink()) {
|
|
151
|
+
errors.push(`${SKILLS_DIR}/${d.name}: is a symlink`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!d.isDirectory()) continue;
|
|
155
|
+
installed.add(d.name);
|
|
156
|
+
const rel = `${SKILLS_DIR}/${d.name}/SKILL.md`;
|
|
157
|
+
const text = read(rel);
|
|
158
|
+
if (text === null) {
|
|
159
|
+
errors.push(`${rel}: missing`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
checkSkillFile(text, d.name, rel, errors);
|
|
163
|
+
skillTexts.push([rel, text]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const known = new Set([...kitSkillNames(units).keys(), ...installed]);
|
|
167
|
+
const ctx = { known, available: installed };
|
|
168
|
+
|
|
169
|
+
// AGENTS.md index and drift
|
|
170
|
+
const agents = read('AGENTS.md');
|
|
171
|
+
if (agents === null) {
|
|
172
|
+
if (installed.size) errors.push('AGENTS.md: missing (skills are installed but not indexed)');
|
|
173
|
+
} else {
|
|
174
|
+
const { errors: blockErrors } = findBlocks(agents);
|
|
175
|
+
for (const e of blockErrors) errors.push(`AGENTS.md: ${e}`);
|
|
176
|
+
const indexed = new Set(indexedSkills(blockRegions(agents)));
|
|
177
|
+
for (const n of indexed) {
|
|
178
|
+
if (!installed.has(n)) errors.push(`AGENTS.md: index lists skill \`${n}\` but ${SKILLS_DIR}/${n}/ does not exist`);
|
|
179
|
+
}
|
|
180
|
+
for (const n of installed) {
|
|
181
|
+
if (!indexed.has(n)) errors.push(`AGENTS.md: installed skill \`${n}\` is missing from the index`);
|
|
182
|
+
}
|
|
183
|
+
checkRefs(agents, 'AGENTS.md', ctx, errors);
|
|
184
|
+
}
|
|
185
|
+
for (const [rel, text] of skillTexts) checkRefs(text, rel, ctx, errors);
|
|
186
|
+
for (const rel of ['CLAUDE.md', 'MEMORY.md']) {
|
|
187
|
+
const text = read(rel);
|
|
188
|
+
if (text !== null) checkRefs(text, rel, ctx, errors);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Managed blocks in other files, JSON/YAML of kit-managed files, hooks
|
|
192
|
+
const allEntries = [units.core, ...units.packs.values(), ...units.presets.values()].flatMap((u) => u.entries);
|
|
193
|
+
const blockDests = new Set(allEntries.filter((e) => e.kind === 'block' && e.dest !== 'AGENTS.md').map((e) => e.dest));
|
|
194
|
+
for (const rel of blockDests) {
|
|
195
|
+
const text = read(rel);
|
|
196
|
+
if (text === null) continue;
|
|
197
|
+
for (const e of findBlocks(text).errors) errors.push(`${rel}: ${e}`);
|
|
198
|
+
}
|
|
199
|
+
const fileDests = new Set(allEntries.filter((e) => e.kind !== 'block').map((e) => e.dest));
|
|
200
|
+
for (const rel of [...fileDests].sort()) {
|
|
201
|
+
if (rel.endsWith('.json')) {
|
|
202
|
+
const text = read(rel);
|
|
203
|
+
if (text !== null) checkJson(text, rel, errors);
|
|
204
|
+
} else if (/\.ya?ml$/.test(rel)) {
|
|
205
|
+
const text = read(rel);
|
|
206
|
+
if (text !== null) lightYamlCheck(text, rel, errors);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const huskyAbs = path.join(root, '.husky');
|
|
210
|
+
if (fs.existsSync(huskyAbs) && fs.lstatSync(huskyAbs).isDirectory()) {
|
|
211
|
+
for (const d of fs.readdirSync(huskyAbs, { withFileTypes: true })) {
|
|
212
|
+
if (!d.isFile()) continue;
|
|
213
|
+
if (platform === 'win32') {
|
|
214
|
+
notes.push(`.husky/${d.name}: exec bit not checked on Windows`);
|
|
215
|
+
} else if ((fs.statSync(path.join(huskyAbs, d.name)).mode & 0o111) === 0) {
|
|
216
|
+
warnings.push(`.husky/${d.name}: hook is not executable (chmod +x)`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { errors, warnings, notes };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Validate this kit's own template tree. */
|
|
224
|
+
export function validateTemplate(/** @type {{ kitRoot: string }} */ { kitRoot }) {
|
|
225
|
+
/** @type {string[]} */
|
|
226
|
+
const errors = [];
|
|
227
|
+
/** @type {string[]} */
|
|
228
|
+
const warnings = [];
|
|
229
|
+
const notes = ['YAML files get a light check only (non-empty, no tab indentation), not a full parse.'];
|
|
230
|
+
let units;
|
|
231
|
+
try {
|
|
232
|
+
units = loadUnits(kitRoot);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
return { errors: [/** @type {Error} */ (e).message], warnings, notes };
|
|
235
|
+
}
|
|
236
|
+
const all = [units.core, ...units.packs.values(), ...units.presets.values()];
|
|
237
|
+
const kitSkills = kitSkillNames(units);
|
|
238
|
+
const known = new Set(kitSkills.keys());
|
|
239
|
+
/** @type {Map<string, { name: string, type: string }>} copy destination -> unit */
|
|
240
|
+
const copyDests = new Map();
|
|
241
|
+
/** @type {Map<string, string>} dest#id -> unit name */
|
|
242
|
+
const blockKeys = new Map();
|
|
243
|
+
|
|
244
|
+
for (const u of all) {
|
|
245
|
+
const label = path.relative(kitRoot, u.dir).split(path.sep).join('/');
|
|
246
|
+
const referenced = new Set(['manifest.txt']);
|
|
247
|
+
/** @type {Set<string>} */
|
|
248
|
+
const ownSkills = new Set();
|
|
249
|
+
/** @type {string[]} */
|
|
250
|
+
const indexed = [];
|
|
251
|
+
// A unit may reference skills from core, itself, and packs it implies (transitively).
|
|
252
|
+
const reach = new Set(['core', u.name]);
|
|
253
|
+
const queue = [...u.implies];
|
|
254
|
+
for (let p = queue.shift(); p !== undefined; p = queue.shift()) {
|
|
255
|
+
if (reach.has(p)) continue;
|
|
256
|
+
const pack = units.packs.get(p);
|
|
257
|
+
if (!pack) {
|
|
258
|
+
errors.push(`${label}/manifest.txt: implies ${p}, which is not a pack (packs: ${[...units.packs.keys()].join(', ')})`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
reach.add(p);
|
|
262
|
+
queue.push(...pack.implies);
|
|
263
|
+
}
|
|
264
|
+
if (u.implies.includes(u.name)) errors.push(`${label}/manifest.txt: a pack cannot imply itself`);
|
|
265
|
+
const available = new Set([...kitSkills].filter(([, owner]) => reach.has(owner)).map(([n]) => n));
|
|
266
|
+
for (const e of u.entries) {
|
|
267
|
+
const where = `${label}/manifest.txt:${e.line}`;
|
|
268
|
+
referenced.add(e.source);
|
|
269
|
+
const abs = path.join(u.dir, ...e.source.split('/'));
|
|
270
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
|
271
|
+
errors.push(`${where}: source ${label}/${e.source} does not exist`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (e.requires && !units.packs.has(e.requires)) {
|
|
275
|
+
errors.push(`${where}: requires=${e.requires} is not a pack (packs: ${[...units.packs.keys()].join(', ')})`);
|
|
276
|
+
}
|
|
277
|
+
if (e.requires && u.type === 'pack' && e.requires === u.name) {
|
|
278
|
+
errors.push(`${where}: a pack cannot require itself`);
|
|
279
|
+
}
|
|
280
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
281
|
+
if (e.kind === 'copy') {
|
|
282
|
+
// Presets are alternatives (one per repository), so two presets may ship the same
|
|
283
|
+
// destination; the installer refuses to combine presets whose contents differ.
|
|
284
|
+
// Core and packs combine with everything, so they must not share a destination.
|
|
285
|
+
const prev = copyDests.get(e.dest);
|
|
286
|
+
if (prev && !(prev.type === 'preset' && u.type === 'preset')) {
|
|
287
|
+
errors.push(`${where}: ${e.dest} is also copied by ${prev.name}`);
|
|
288
|
+
} else if (!prev) copyDests.set(e.dest, { name: u.name, type: u.type });
|
|
289
|
+
}
|
|
290
|
+
if (e.kind === 'block') {
|
|
291
|
+
const key = `${e.dest}#${e.id}`;
|
|
292
|
+
if (blockKeys.has(key)) errors.push(`${where}: block ${e.id} in ${e.dest} is also defined by ${blockKeys.get(key)}`);
|
|
293
|
+
else blockKeys.set(key, u.name);
|
|
294
|
+
if (/\b(BEGIN|END)\s+agent-kit:/.test(text)) errors.push(`${where}: fragment contains an agent-kit marker`);
|
|
295
|
+
if (e.dest === 'AGENTS.md') indexed.push(...indexedSkills(text));
|
|
296
|
+
checkRefs(text, `${label}/${e.source}`, { known, available }, errors);
|
|
297
|
+
}
|
|
298
|
+
const skill = /^\.claude\/skills\/([^/]+)\/SKILL\.md$/.exec(e.dest);
|
|
299
|
+
if (skill && e.kind !== 'block') {
|
|
300
|
+
ownSkills.add(skill[1]);
|
|
301
|
+
checkSkillFile(text, skill[1], `${label}/${e.source}`, errors);
|
|
302
|
+
checkRefs(text, `${label}/${e.source}`, { known, available }, errors);
|
|
303
|
+
}
|
|
304
|
+
if (e.kind !== 'block' && ['CLAUDE.md', 'MEMORY.md'].includes(e.dest)) {
|
|
305
|
+
checkRefs(text, `${label}/${e.source}`, { known, available }, errors);
|
|
306
|
+
}
|
|
307
|
+
if (e.dest.endsWith('.json')) checkJson(text, `${label}/${e.source}`, errors);
|
|
308
|
+
if (/\.ya?ml$/.test(e.dest)) lightYamlCheck(text, `${label}/${e.source}`, errors);
|
|
309
|
+
}
|
|
310
|
+
if (u.type === 'preset' && u.runs.length === 0) {
|
|
311
|
+
errors.push(`${label}/manifest.txt: a preset needs at least one "# run: <command>" line (setup commands the installer prints)`);
|
|
312
|
+
}
|
|
313
|
+
for (const f of walkFiles(u.dir)) {
|
|
314
|
+
if (!referenced.has(f)) errors.push(`${label}/${f}: orphan file (not referenced by ${label}/manifest.txt)`);
|
|
315
|
+
}
|
|
316
|
+
for (const n of ownSkills) {
|
|
317
|
+
if (!indexed.includes(n)) errors.push(`${label}: skill \`${n}\` is not listed in the unit's AGENTS.md fragment`);
|
|
318
|
+
}
|
|
319
|
+
for (const n of indexed) {
|
|
320
|
+
if (!ownSkills.has(n)) errors.push(`${label}: AGENTS.md fragment lists \`${n}\`, which this unit does not ship`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { errors, warnings, notes };
|
|
324
|
+
}
|