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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -2
  3. package/bin/agent-kit.js +6 -0
  4. package/core/files/.claude/settings.json +16 -0
  5. package/core/files/.claude/skills/agent-memory-protocol/SKILL.md +117 -0
  6. package/core/files/.claude/skills/api-design-rest/SKILL.md +126 -0
  7. package/core/files/.claude/skills/backend-architecture-principles/SKILL.md +116 -0
  8. package/core/files/.claude/skills/backend-security/SKILL.md +117 -0
  9. package/core/files/.claude/skills/changelog-and-versioning/SKILL.md +169 -0
  10. package/core/files/.claude/skills/database-best-practices/SKILL.md +104 -0
  11. package/core/files/.claude/skills/git-commit-and-workflow/SKILL.md +140 -0
  12. package/core/files/.claude/skills/project-management-workflow/SKILL.md +181 -0
  13. package/core/files/CLAUDE.md +1 -0
  14. package/core/files/MEMORY.md +77 -0
  15. package/core/files/docs/adr/0000-template.md +21 -0
  16. package/core/fragments/agents-core.md +61 -0
  17. package/core/manifest.txt +21 -0
  18. package/docs/monorepo.md +72 -0
  19. package/package.json +50 -4
  20. package/packs/github/files/.github/ISSUE_TEMPLATE/bug_report.md +22 -0
  21. package/packs/github/files/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  22. package/packs/github/files/.github/pull_request_template.md +24 -0
  23. package/packs/github/files/.github/workflows/pr-title.yml +37 -0
  24. package/packs/github/manifest.txt +6 -0
  25. package/packs/web/files/.claude/skills/html-syntax-and-semantics/SKILL.md +127 -0
  26. package/packs/web/files/.claude/skills/seo-best-practices/SKILL.md +158 -0
  27. package/packs/web/fragments/agents-web.md +11 -0
  28. package/packs/web/manifest.txt +5 -0
  29. package/presets/node-backend/docs/node-backend.md +60 -0
  30. package/presets/node-backend/files/.editorconfig +12 -0
  31. package/presets/node-backend/files/.husky/commit-msg +1 -0
  32. package/presets/node-backend/files/.husky/pre-commit +1 -0
  33. package/presets/node-backend/files/.lintstagedrc.json +4 -0
  34. package/presets/node-backend/files/.prettierrc.json +7 -0
  35. package/presets/node-backend/files/.release-please-manifest.json +3 -0
  36. package/presets/node-backend/files/CHANGELOG.md +5 -0
  37. package/presets/node-backend/files/commitlint.config.mjs +12 -0
  38. package/presets/node-backend/files/eslint.config.mjs +38 -0
  39. package/presets/node-backend/files/release-please-config.json +25 -0
  40. package/presets/node-backend/fragments/agents-node-backend-github.md +7 -0
  41. package/presets/node-backend/fragments/agents-node-backend.md +13 -0
  42. package/presets/node-backend/fragments/gitignore.txt +19 -0
  43. package/presets/node-backend/fragments/prettierignore.txt +16 -0
  44. package/presets/node-backend/github/dependabot.yml +21 -0
  45. package/presets/node-backend/github/workflows/ci.yml +38 -0
  46. package/presets/node-backend/github/workflows/release-please.yml +21 -0
  47. package/presets/node-backend/manifest.txt +30 -0
  48. package/presets/python/docs/python.md +66 -0
  49. package/presets/python/files/.editorconfig +15 -0
  50. package/presets/python/files/.pre-commit-config.yaml +32 -0
  51. package/presets/python/files/.release-please-manifest.json +3 -0
  52. package/presets/python/files/CHANGELOG.md +5 -0
  53. package/presets/python/files/mypy.ini +5 -0
  54. package/presets/python/files/pytest.ini +6 -0
  55. package/presets/python/files/release-please-config.json +25 -0
  56. package/presets/python/files/ruff.toml +17 -0
  57. package/presets/python/fragments/agents-python-github.md +7 -0
  58. package/presets/python/fragments/agents-python.md +14 -0
  59. package/presets/python/fragments/gitignore.txt +25 -0
  60. package/presets/python/github/dependabot.yml +18 -0
  61. package/presets/python/github/workflows/ci.yml +37 -0
  62. package/presets/python/github/workflows/release-please.yml +21 -0
  63. package/presets/python/manifest.txt +25 -0
  64. package/presets/react-typescript/docs/react-typescript.md +71 -0
  65. package/presets/react-typescript/files/.claude/skills/react-principles/SKILL.md +264 -0
  66. package/presets/react-typescript/files/.editorconfig +12 -0
  67. package/presets/react-typescript/files/.husky/commit-msg +1 -0
  68. package/presets/react-typescript/files/.husky/pre-commit +1 -0
  69. package/presets/react-typescript/files/.lintstagedrc.json +4 -0
  70. package/presets/react-typescript/files/.prettierrc.json +7 -0
  71. package/presets/react-typescript/files/.release-please-manifest.json +3 -0
  72. package/presets/react-typescript/files/CHANGELOG.md +5 -0
  73. package/presets/react-typescript/files/commitlint.config.mjs +12 -0
  74. package/presets/react-typescript/files/eslint.config.mjs +55 -0
  75. package/presets/react-typescript/files/release-please-config.json +25 -0
  76. package/presets/react-typescript/fragments/agents-react-typescript-github.md +7 -0
  77. package/presets/react-typescript/fragments/agents-react-typescript.md +17 -0
  78. package/presets/react-typescript/fragments/gitignore.txt +22 -0
  79. package/presets/react-typescript/fragments/prettierignore.txt +17 -0
  80. package/presets/react-typescript/github/dependabot.yml +21 -0
  81. package/presets/react-typescript/github/workflows/ci.yml +38 -0
  82. package/presets/react-typescript/github/workflows/release-please.yml +21 -0
  83. package/presets/react-typescript/manifest.txt +32 -0
  84. package/src/blocks.js +132 -0
  85. package/src/cli.js +159 -0
  86. package/src/fsutil.js +123 -0
  87. package/src/install.js +223 -0
  88. package/src/manifest.js +187 -0
  89. package/src/validate.js +324 -0
package/src/blocks.js ADDED
@@ -0,0 +1,132 @@
1
+ // Managed blocks: regions between BEGIN/END markers that the installer owns.
2
+ //
3
+ // style=html: <!-- BEGIN agent-kit:<id> --> ... <!-- END agent-kit:<id> -->
4
+ // style=hash: # BEGIN agent-kit:<id> ... # END agent-kit:<id>
5
+ //
6
+ // Content outside the markers is preserved byte for byte. When updating an
7
+ // existing file, the inserted block uses that file's line ending (LF or CRLF).
8
+
9
+ const MARKER_RE = /^(?:<!--\s*(BEGIN|END) agent-kit:([a-z0-9][a-z0-9-]*)\s*-->|#\s*(BEGIN|END) agent-kit:([a-z0-9][a-z0-9-]*))$/;
10
+ // Anything that looks like it is trying to be a marker.
11
+ const MARKERISH_RE = /\b(BEGIN|END)\s+agent-kit:/;
12
+
13
+ export function markers(/** @type {string} */ id, style = 'html') {
14
+ if (style === 'hash') return { begin: `# BEGIN agent-kit:${id}`, end: `# END agent-kit:${id}` };
15
+ return { begin: `<!-- BEGIN agent-kit:${id} -->`, end: `<!-- END agent-kit:${id} -->` };
16
+ }
17
+
18
+ /** "\r\n" if the first line break in the text is CRLF, else "\n". */
19
+ export function detectEol(/** @type {string} */ text) {
20
+ const i = text.indexOf('\n');
21
+ return i > 0 && text[i - 1] === '\r' ? '\r\n' : '\n';
22
+ }
23
+
24
+ /** Split into lines, remembering each line's start/end offsets (end excludes the line break). */
25
+ function splitLines(/** @type {string} */ text) {
26
+ const lines = [];
27
+ let start = 0;
28
+ while (start <= text.length) {
29
+ let nl = text.indexOf('\n', start);
30
+ const last = nl === -1;
31
+ if (last) nl = text.length;
32
+ const end = nl > start && text[nl - 1] === '\r' ? nl - 1 : nl;
33
+ lines.push({ text: text.slice(start, end), start, end, index: lines.length });
34
+ if (last) break;
35
+ start = nl + 1;
36
+ }
37
+ return lines;
38
+ }
39
+
40
+ /**
41
+ * Find managed blocks in text.
42
+ * Returns { blocks: [{ id, style, start, end }], errors: [string] }.
43
+ * `start` is the offset of the BEGIN line, `end` the offset just after the END
44
+ * marker text (before its line break). Any error means the file must not be edited.
45
+ */
46
+ export function findBlocks(/** @type {string} */ text) {
47
+ const blocks = [];
48
+ const errors = [];
49
+ const seen = new Set();
50
+ let open = null;
51
+ for (const line of splitLines(text)) {
52
+ const trimmed = line.text.trim();
53
+ const m = MARKER_RE.exec(trimmed);
54
+ if (!m) {
55
+ if (MARKERISH_RE.test(line.text)) errors.push(`line ${line.index + 1}: unrecognized agent-kit marker`);
56
+ continue;
57
+ }
58
+ const kind = m[1] ?? m[3];
59
+ const id = m[2] ?? m[4];
60
+ const style = m[1] ? 'html' : 'hash';
61
+ if (kind === 'BEGIN') {
62
+ if (open) {
63
+ errors.push(`line ${line.index + 1}: BEGIN ${id} inside open block ${open.id} (nested)`);
64
+ continue;
65
+ }
66
+ if (seen.has(id)) errors.push(`line ${line.index + 1}: duplicate block ${id}`);
67
+ seen.add(id);
68
+ open = { id, style, start: line.start, line: line.index + 1 };
69
+ } else {
70
+ if (!open) {
71
+ errors.push(`line ${line.index + 1}: END ${id} without BEGIN`);
72
+ continue;
73
+ }
74
+ if (open.id !== id || open.style !== style) {
75
+ errors.push(`line ${line.index + 1}: END ${id} does not match BEGIN ${open.id} on line ${open.line}`);
76
+ open = null;
77
+ continue;
78
+ }
79
+ blocks.push({ id, style, start: open.start, end: line.end });
80
+ open = null;
81
+ }
82
+ }
83
+ if (open) errors.push(`line ${open.line}: BEGIN ${open.id} is never closed`);
84
+ return { blocks, errors };
85
+ }
86
+
87
+ /** Render a block (markers + content) with the given line ending, without a trailing break. */
88
+ export function renderBlock(/** @type {string} */ id, /** @type {string} */ style, /** @type {string} */ content, eol = '\n') {
89
+ const { begin, end } = markers(id, style);
90
+ const body = content.replace(/\r\n/g, '\n').replace(/\n+$/, '');
91
+ const lines = [begin, ...(body ? body.split('\n') : []), end];
92
+ return lines.join(eol);
93
+ }
94
+
95
+ /** Throws if fragment content itself contains marker-like lines. */
96
+ export function assertFragmentClean(/** @type {string} */ content, /** @type {string} */ label) {
97
+ if (MARKERISH_RE.test(content)) throw new Error(`${label}: fragment contains an agent-kit marker`);
98
+ }
99
+
100
+ /**
101
+ * Compute the new text for a file after inserting/updating one block.
102
+ * existing: current file text, or null if the file does not exist.
103
+ * Returns { status: 'block-create'|'block-update'|'block-unchanged'|'conflict', text?, reason? }.
104
+ */
105
+ export function applyBlock(
106
+ /** @type {string | null} */ existing,
107
+ /** @type {{ id: string, style?: string, content: string, title?: string | null }} */ { id, style = 'html', content, title = null },
108
+ ) {
109
+ if (existing === null) {
110
+ const block = renderBlock(id, style, content, '\n');
111
+ return { status: 'block-create', text: (title ? `${title}\n\n` : '') + block + '\n' };
112
+ }
113
+ const { blocks, errors } = findBlocks(existing);
114
+ if (errors.length) return { status: 'conflict', reason: `malformed markers: ${errors.join('; ')}` };
115
+ const eol = detectEol(existing);
116
+ const block = renderBlock(id, style, content, eol);
117
+ const current = blocks.find((b) => b.id === id);
118
+ if (current) {
119
+ if (current.style !== style) {
120
+ return { status: 'conflict', reason: `block ${id} uses ${current.style} markers, expected ${style}` };
121
+ }
122
+ const text = existing.slice(0, current.start) + block + existing.slice(current.end);
123
+ return text === existing ? { status: 'block-unchanged', text } : { status: 'block-update', text };
124
+ }
125
+ let text = existing;
126
+ if (text.length > 0) {
127
+ if (!text.endsWith('\n')) text += eol;
128
+ text += eol;
129
+ }
130
+ text += block + eol;
131
+ return { status: 'block-create', text };
132
+ }
package/src/cli.js ADDED
@@ -0,0 +1,159 @@
1
+ // agents-md-kit CLI logic. Exit codes: 0 success, 1 error, 2 install finished with conflicts.
2
+
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { install, UsageError } from './install.js';
6
+ import { validateRepo, validateTemplate } from './validate.js';
7
+ import { loadUnits } from './manifest.js';
8
+
9
+ const KIT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+
11
+ const USAGE = `Usage:
12
+ agents-md-kit install [--preset <name>]... [--pack <name>]... [--target <dir>] [--apply]
13
+ agents-md-kit validate [--target <dir>] [--template]
14
+ agents-md-kit list
15
+
16
+ install is a dry run unless --apply is given. core is always installed.`;
17
+
18
+ export function parseArgs(/** @type {string[]} */ argv) {
19
+ const [command, ...rest] = argv;
20
+ /** @type {{ command: string, presets: string[], packs: string[], target: string, apply: boolean, template: boolean, help: boolean }} */
21
+ const opts = { command, presets: [], packs: [], target: '.', apply: false, template: false, help: false };
22
+ /** @type {Record<string, string[]>} */
23
+ const allowed = {
24
+ install: ['--preset', '--pack', '--target', '--apply'],
25
+ validate: ['--target', '--template'],
26
+ list: [],
27
+ };
28
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
29
+ opts.help = true;
30
+ return opts;
31
+ }
32
+ if (!(command in allowed)) throw new UsageError(`unknown command "${command}"\n\n${USAGE}`);
33
+ for (let i = 0; i < rest.length; i++) {
34
+ let arg = rest[i];
35
+ let value;
36
+ const eq = arg.indexOf('=');
37
+ if (arg.startsWith('--') && eq > 0) {
38
+ value = arg.slice(eq + 1);
39
+ arg = arg.slice(0, eq);
40
+ }
41
+ if (arg === '--help' || arg === '-h') {
42
+ opts.help = true;
43
+ continue;
44
+ }
45
+ if (!allowed[command].includes(arg)) throw new UsageError(`unknown option "${arg}" for ${command}\n\n${USAGE}`);
46
+ const needsValue = ['--preset', '--pack', '--target'].includes(arg);
47
+ if (needsValue) {
48
+ if (value === undefined) {
49
+ value = rest[++i];
50
+ if (value === undefined || value.startsWith('--')) throw new UsageError(`${arg} needs a value`);
51
+ }
52
+ if (arg === '--preset') opts.presets.push(value);
53
+ else if (arg === '--pack') opts.packs.push(value);
54
+ else opts.target = value;
55
+ } else {
56
+ if (value !== undefined) throw new UsageError(`${arg} takes no value`);
57
+ if (arg === '--apply') opts.apply = true;
58
+ if (arg === '--template') opts.template = true;
59
+ }
60
+ }
61
+ return opts;
62
+ }
63
+
64
+ /** @typedef {(line: string) => void} Print */
65
+
66
+ function printReport(
67
+ /** @type {{ errors: string[], warnings: string[], notes: string[] }} */ { errors, warnings, notes },
68
+ /** @type {Print} */ out,
69
+ ) {
70
+ for (const e of errors) out(`error ${e}`);
71
+ for (const w of warnings) out(`warning ${w}`);
72
+ for (const n of notes) out(`note ${n}`);
73
+ }
74
+
75
+ export function main(/** @type {string[]} */ argv, { out = console.log, err = console.error, cwd = process.cwd(), kitRoot = KIT_ROOT } = {}) {
76
+ let opts;
77
+ try {
78
+ opts = parseArgs(argv);
79
+ } catch (e) {
80
+ err(/** @type {Error} */ (e).message);
81
+ return 1;
82
+ }
83
+ if (opts.help) {
84
+ out(USAGE);
85
+ return 0;
86
+ }
87
+ const target = path.resolve(cwd, opts.target);
88
+ try {
89
+ if (opts.command === 'list') {
90
+ const units = loadUnits(kitRoot);
91
+ out(`core (always installed)\n ${units.core.description}`);
92
+ for (const [label, map] of /** @type {const} */ ([
93
+ ['packs (--pack)', units.packs],
94
+ ['presets (--preset)', units.presets],
95
+ ])) {
96
+ out(`\n${label}:`);
97
+ if (map.size === 0) out(' (none)');
98
+ for (const u of map.values()) {
99
+ const inc = u.implies.length ? ` (also installs pack ${u.implies.join(', ')})` : '';
100
+ out(` ${u.name.padEnd(18)} ${u.description}${inc}`);
101
+ }
102
+ }
103
+ return 0;
104
+ }
105
+ if (opts.command === 'validate') {
106
+ const report = opts.template ? validateTemplate({ kitRoot }) : validateRepo({ target, kitRoot });
107
+ printReport(report, out);
108
+ const what = opts.template ? `template ${kitRoot}` : target;
109
+ out(
110
+ report.errors.length
111
+ ? `\nvalidate: FAILED with ${report.errors.length} error(s) in ${what}`
112
+ : `\nvalidate: OK (${report.warnings.length} warning(s)) in ${what}`,
113
+ );
114
+ return report.errors.length ? 1 : 0;
115
+ }
116
+ // install
117
+ const res = install({ kitRoot, target, presets: opts.presets, packs: opts.packs, apply: opts.apply });
118
+ const width = Math.max(...res.actions.map((a) => a.op.length), 6);
119
+ for (const a of res.actions) out(`${a.op.padEnd(width)} ${a.path}${a.detail ? ` (${a.detail})` : ''}`);
120
+ /** @type {Record<string, number>} */
121
+ const counts = {};
122
+ for (const a of res.actions) {
123
+ const key = a.op.startsWith('conflict') ? 'conflict' : a.op.startsWith('skip (needs') ? 'skip (needs pack)' : a.op;
124
+ counts[key] = (counts[key] ?? 0) + 1;
125
+ }
126
+ out('');
127
+ out(`Summary: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')}`);
128
+ for (const n of res.notes) out(`note: ${n}`);
129
+ if (res.conflicts) out(`${res.conflicts} conflict(s): review each <path>.new, merge by hand, then delete the .new file.`);
130
+ if (!res.applied) {
131
+ out(`Dry run: nothing was written to ${res.root}. Re-run with --apply to write ${res.writes} file(s).`);
132
+ } else {
133
+ out(`Applied: wrote ${res.writes} file(s) to ${res.root}.`);
134
+ const binAbs = path.join(kitRoot, 'bin', 'agent-kit.js');
135
+ const binRel = path.relative(cwd, binAbs);
136
+ const bin = binRel.startsWith('..') || path.isAbsolute(binRel) ? binAbs : binRel;
137
+ const q = (/** @type {string} */ s) => (/\s/.test(s) ? `"${s}"` : s);
138
+ // Installed from npm (npx or a dependency): the path into node_modules is no use to the reader.
139
+ const viaNpm = kitRoot.split(path.sep).includes('node_modules');
140
+ out(`Next: ${viaNpm ? 'npx agents-md-kit' : `node ${q(bin)}`} validate --target ${q(opts.target)}`);
141
+ }
142
+ for (const doc of res.nextSteps) out(`Next steps for this preset: ${path.join(res.root, ...doc.split('/'))}`);
143
+ if (res.commands.length) {
144
+ out('');
145
+ out(
146
+ `Run these yourself in ${res.root}${res.applied ? '' : ' (after --apply)'}; ` +
147
+ 'agents-md-kit never edits package.json, pyproject.toml, README.md or lockfiles:',
148
+ );
149
+ for (const c of res.commands) out(` ${c}`);
150
+ } else {
151
+ out('The installer never touches package.json, pyproject.toml, README.md or lockfiles.');
152
+ }
153
+ return res.conflicts ? 2 : 0;
154
+ } catch (e) {
155
+ err(`error: ${/** @type {Error} */ (e).message}`);
156
+ return 1;
157
+ }
158
+ }
159
+
package/src/fsutil.js ADDED
@@ -0,0 +1,123 @@
1
+ // Filesystem helpers that keep every write inside the target and never follow symlinks.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { checkRelPath } from './manifest.js';
6
+
7
+ export class SafetyError extends Error {}
8
+
9
+ /** Resolve a manifest-relative path inside root, or throw. */
10
+ export function resolveInside(/** @type {string} */ root, /** @type {string} */ rel) {
11
+ const err = checkRelPath(rel);
12
+ if (err) throw new SafetyError(`unsafe path "${rel}": ${err}`);
13
+ const abs = path.resolve(root, ...rel.split('/'));
14
+ const back = path.relative(root, abs);
15
+ if (back === '' || back.startsWith('..') || path.isAbsolute(back)) {
16
+ throw new SafetyError(`path "${rel}" resolves outside ${root}`);
17
+ }
18
+ return abs;
19
+ }
20
+
21
+ function lstatOrNull(/** @type {string} */ p) {
22
+ try {
23
+ return fs.lstatSync(p);
24
+ } catch (e) {
25
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT' || /** @type {NodeJS.ErrnoException} */ (e).code === 'ENOTDIR') return null;
26
+ throw e;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Throw if any existing component of root/rel (below root) is a symlink or
32
+ * junction, or if an intermediate component exists and is not a directory.
33
+ * Returns the lstat of the leaf (or null when it does not exist).
34
+ */
35
+ export function assertNoSymlinks(/** @type {string} */ root, /** @type {string} */ rel) {
36
+ resolveInside(root, rel);
37
+ const parts = rel.split('/');
38
+ let cur = root;
39
+ for (let i = 0; i < parts.length; i++) {
40
+ cur = path.join(cur, parts[i]);
41
+ const st = lstatOrNull(cur);
42
+ if (!st) return null;
43
+ if (st.isSymbolicLink()) {
44
+ throw new SafetyError(`refusing to follow symlink: ${parts.slice(0, i + 1).join('/')}`);
45
+ }
46
+ const isLeaf = i === parts.length - 1;
47
+ if (!isLeaf && !st.isDirectory()) {
48
+ throw new SafetyError(`not a directory: ${parts.slice(0, i + 1).join('/')}`);
49
+ }
50
+ if (isLeaf) {
51
+ if (!st.isFile()) throw new SafetyError(`not a regular file: ${rel}`);
52
+ return st;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** Read root/rel as a Buffer after symlink checks; null if missing. */
59
+ export function readTargetFile(/** @type {string} */ root, /** @type {string} */ rel) {
60
+ const st = assertNoSymlinks(root, rel);
61
+ if (!st) return null;
62
+ return fs.readFileSync(resolveInside(root, rel));
63
+ }
64
+
65
+ /** Create parent directories one level at a time, refusing symlinks along the way. */
66
+ function mkdirsSafe(/** @type {string} */ root, /** @type {string} */ rel) {
67
+ const parts = rel.split('/').slice(0, -1);
68
+ let cur = root;
69
+ for (let i = 0; i < parts.length; i++) {
70
+ cur = path.join(cur, parts[i]);
71
+ const st = lstatOrNull(cur);
72
+ if (st && st.isSymbolicLink()) {
73
+ throw new SafetyError(`refusing to follow symlink: ${parts.slice(0, i + 1).join('/')}`);
74
+ }
75
+ if (st && !st.isDirectory()) throw new SafetyError(`not a directory: ${parts.slice(0, i + 1).join('/')}`);
76
+ if (!st) fs.mkdirSync(cur);
77
+ }
78
+ }
79
+
80
+ /** Write root/rel (creating parents) after re-checking that nothing on the path is a symlink. */
81
+ export function writeTargetFile(
82
+ /** @type {string} */ root,
83
+ /** @type {string} */ rel,
84
+ /** @type {Buffer} */ data,
85
+ /** @type {{ mode?: number }} */ { mode } = {},
86
+ ) {
87
+ assertNoSymlinks(root, rel);
88
+ mkdirsSafe(root, rel);
89
+ const abs = resolveInside(root, rel);
90
+ fs.writeFileSync(abs, data);
91
+ if (mode !== undefined) fs.chmodSync(abs, mode);
92
+ }
93
+
94
+ /** Text equality that ignores LF vs CRLF differences. */
95
+ export function sameText(/** @type {Buffer} */ a, /** @type {Buffer} */ b) {
96
+ if (a.equals(b)) return true;
97
+ const norm = (/** @type {Buffer} */ buf) => buf.toString('utf8').replace(/\r\n/g, '\n');
98
+ return norm(a) === norm(b);
99
+ }
100
+
101
+ /**
102
+ * Recursively list files (relative, forward slashes) under dir. Skips symlinks.
103
+ * @returns {string[]}
104
+ */
105
+ export function walkFiles(/** @type {string} */ dir, base = dir, /** @type {Set<string>} */ skip = new Set()) {
106
+ /** @type {string[]} */
107
+ let out = [];
108
+ let entries;
109
+ try {
110
+ entries = fs.readdirSync(dir, { withFileTypes: true });
111
+ } catch (e) {
112
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') return out;
113
+ throw e;
114
+ }
115
+ for (const d of entries) {
116
+ if (skip.has(d.name)) continue;
117
+ const abs = path.join(dir, d.name);
118
+ if (d.isSymbolicLink()) continue;
119
+ if (d.isDirectory()) out = out.concat(walkFiles(abs, base, skip));
120
+ else if (d.isFile()) out.push(path.relative(base, abs).split(path.sep).join('/'));
121
+ }
122
+ return out.sort();
123
+ }
package/src/install.js ADDED
@@ -0,0 +1,223 @@
1
+ // Installer: plan every change in memory first, then (only with apply) write.
2
+ // Any error during planning aborts before a single byte is written.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { loadUnits } from './manifest.js';
7
+ import { applyBlock, assertFragmentClean, renderBlock } from './blocks.js';
8
+ import { assertNoSymlinks, readTargetFile, resolveInside, sameText, writeTargetFile } from './fsutil.js';
9
+
10
+ export class UsageError extends Error {}
11
+
12
+ /**
13
+ * @typedef {import('./manifest.js').Unit} Unit
14
+ * @typedef {import('./manifest.js').Units} Units
15
+ * @typedef {{ op: string, path: string, unit: string, detail?: string }} Action
16
+ * @typedef {{ rel: string, data: Buffer, mode?: number }} Write
17
+ */
18
+
19
+ const isHook = (/** @type {string} */ rel) => /^\.husky\/[^/]+$/.test(rel);
20
+
21
+ /** Pick units in a stable order: core, selected packs, selected presets (kit order). */
22
+ export function selectUnits(
23
+ /** @type {Units} */ units,
24
+ /** @type {{ presets?: string[], packs?: string[] }} */ { presets = [], packs = [] } = {},
25
+ ) {
26
+ const check = (/** @type {string[]} */ names, /** @type {Map<string, Unit>} */ map, /** @type {string} */ kind) => {
27
+ for (const n of names) {
28
+ if (!map.has(n)) {
29
+ const valid = [...map.keys()];
30
+ throw new UsageError(
31
+ `unknown ${kind} "${n}". Valid ${kind}s: ${valid.length ? valid.join(', ') : '(none)'}`,
32
+ );
33
+ }
34
+ }
35
+ };
36
+ check(packs, units.packs, 'pack');
37
+ check(presets, units.presets, 'preset');
38
+ const packSet = new Set(packs);
39
+ const presetSet = new Set(presets);
40
+ // Expand `implies <pack>` transitively (presets and packs may imply packs).
41
+ /** @type {{ pack: string, by: string }[]} */
42
+ const implied = [];
43
+ // Names were checked above, so every lookup finds its unit.
44
+ const queue = /** @type {Unit[]} */ ([
45
+ ...[...presetSet].map((n) => units.presets.get(n)),
46
+ ...[...packSet].map((n) => units.packs.get(n)),
47
+ ]);
48
+ for (let u = queue.shift(); u; u = queue.shift()) {
49
+ for (const p of u.implies) {
50
+ if (!units.packs.has(p)) throw new Error(`${u.type} ${u.name} implies unknown pack "${p}"`);
51
+ if (packSet.has(p)) continue;
52
+ packSet.add(p);
53
+ implied.push({ pack: p, by: `${u.type} ${u.name}` });
54
+ queue.push(/** @type {Unit} */ (units.packs.get(p)));
55
+ }
56
+ }
57
+ return {
58
+ selected: [
59
+ units.core,
60
+ ...[...units.packs.values()].filter((u) => packSet.has(u.name)),
61
+ ...[...units.presets.values()].filter((u) => presetSet.has(u.name)),
62
+ ],
63
+ packs: packSet,
64
+ implied,
65
+ };
66
+ }
67
+
68
+ function readSource(/** @type {Unit} */ unit, /** @type {string} */ rel) {
69
+ const abs = resolveInside(unit.dir, rel);
70
+ try {
71
+ return fs.readFileSync(abs);
72
+ } catch (e) {
73
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') throw new Error(`${unit.type} ${unit.name}: missing source ${rel}`);
74
+ throw e;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Plan (and optionally apply) an install.
80
+ * Returns { actions: [{ op, path, unit, detail? }], conflicts, notes, nextSteps, applied }.
81
+ */
82
+ export function install(
83
+ /** @type {{ kitRoot: string, target: string, presets?: string[], packs?: string[], apply?: boolean, platform?: string }} */
84
+ { kitRoot, target, presets = [], packs = [], apply = false, platform = process.platform },
85
+ ) {
86
+ const units = loadUnits(kitRoot);
87
+ const { selected, packs: selectedPacks, implied } = selectUnits(units, { presets, packs });
88
+
89
+ /** @type {string} */
90
+ let root;
91
+ try {
92
+ root = fs.realpathSync(target);
93
+ } catch {
94
+ throw new UsageError(`target directory does not exist: ${target}`);
95
+ }
96
+ if (!fs.statSync(root).isDirectory()) throw new UsageError(`target is not a directory: ${target}`);
97
+
98
+ /** @type {Map<string, Buffer>} planned content by destination */
99
+ const overlay = new Map();
100
+ /** @type {Write[]} */
101
+ const writes = [];
102
+ /** @type {Action[]} */
103
+ const actions = [];
104
+ const notes = implied.map(({ pack, by }) => `pack ${pack} added because ${by} implies it`);
105
+
106
+ const current = (/** @type {string} */ rel) => {
107
+ const planned = overlay.get(rel);
108
+ if (planned !== undefined) return planned;
109
+ return readTargetFile(root, rel); // also refuses symlinks on the path
110
+ };
111
+ const plan = (/** @type {string} */ rel, /** @type {Buffer} */ data, /** @type {{ mode?: number }} */ extra = {}) => {
112
+ overlay.set(rel, data);
113
+ const existing = writes.findIndex((w) => w.rel === rel);
114
+ if (existing >= 0) writes.splice(existing, 1);
115
+ writes.push({ rel, data, ...extra });
116
+ };
117
+ const conflict = (/** @type {Unit} */ unit, /** @type {string} */ rel, /** @type {Buffer} */ data, /** @type {string} */ reason) => {
118
+ const newRel = `${rel}.new`;
119
+ assertNoSymlinks(root, newRel);
120
+ const prev = current(newRel);
121
+ if (!prev || !prev.equals(data)) plan(newRel, data);
122
+ actions.push({ op: `conflict -> ${newRel}`, path: rel, unit: unit.name, detail: reason });
123
+ };
124
+ // Block conflicts for the same dest are collected, then written as ONE .new
125
+ // holding every rendered block for that dest (in unit order).
126
+ /** @type {Map<string, string[]>} rendered blocks by destination */
127
+ const blockConflicts = new Map();
128
+ /** @type {Map<string, { unit: string, src: Buffer }>} copy/copy-once entries by destination */
129
+ const claimed = new Map();
130
+
131
+ for (const unit of selected) {
132
+ for (const e of unit.entries) {
133
+ if (e.requires && !units.packs.has(e.requires)) {
134
+ throw new Error(`${unit.name}:${e.line}: requires unknown pack "${e.requires}"`);
135
+ }
136
+ if (e.requires && !selectedPacks.has(e.requires)) {
137
+ actions.push({ op: `skip (needs --pack ${e.requires})`, path: e.dest, unit: unit.name });
138
+ continue;
139
+ }
140
+ assertNoSymlinks(root, e.dest);
141
+ if (e.kind === 'copy' || e.kind === 'copy-once') {
142
+ const src = readSource(unit, e.source);
143
+ const prevClaim = claimed.get(e.dest);
144
+ if (prevClaim && !prevClaim.src.equals(src)) {
145
+ throw new UsageError(
146
+ `${prevClaim.unit} and ${unit.name} both install ${e.dest} with different content. ` +
147
+ 'Install one preset per repository (for monorepos see docs/monorepo.md in the kit).',
148
+ );
149
+ }
150
+ claimed.set(e.dest, { unit: unit.name, src });
151
+ const have = current(e.dest);
152
+ const mode = isHook(e.dest) && platform !== 'win32' ? 0o755 : undefined;
153
+ if (have === null) {
154
+ plan(e.dest, src, { mode });
155
+ actions.push({ op: 'create', path: e.dest, unit: unit.name });
156
+ } else if (e.kind === 'copy-once') {
157
+ actions.push({ op: 'skip (exists)', path: e.dest, unit: unit.name });
158
+ } else if (sameText(have, src)) {
159
+ actions.push({ op: 'unchanged', path: e.dest, unit: unit.name });
160
+ } else {
161
+ conflict(unit, e.dest, src, 'existing file differs');
162
+ }
163
+ if (isHook(e.dest) && platform === 'win32') {
164
+ notes.push(`${e.dest}: exec bit not set on Windows; run "git update-index --chmod=+x ${e.dest}" after adding it`);
165
+ }
166
+ } else if (e.kind === 'block') {
167
+ const content = readSource(unit, e.source).toString('utf8');
168
+ assertFragmentClean(content, `${unit.name}/${e.source}`);
169
+ const have = current(e.dest);
170
+ const title = e.dest.endsWith('.md') && e.style === 'html' ? `# ${path.posix.basename(e.dest)}` : null;
171
+ const res = applyBlock(have === null ? null : have.toString('utf8'), {
172
+ id: e.id,
173
+ style: e.style,
174
+ content,
175
+ title,
176
+ });
177
+ if (res.status === 'conflict') {
178
+ const newRel = `${e.dest}.new`;
179
+ assertNoSymlinks(root, newRel);
180
+ const pending = blockConflicts.get(e.dest) ?? [];
181
+ blockConflicts.set(e.dest, pending);
182
+ pending.push(renderBlock(e.id, e.style, content));
183
+ actions.push({ op: `conflict -> ${newRel}`, path: e.dest, unit: unit.name, detail: `block ${e.id}: ${res.reason}` });
184
+ } else {
185
+ if (res.status !== 'block-unchanged') plan(e.dest, Buffer.from(/** @type {string} */ (res.text), 'utf8'));
186
+ actions.push({ op: res.status, path: e.dest, unit: unit.name, detail: `block ${e.id}` });
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ for (const [dest, rendered] of blockConflicts) {
193
+ const newRel = `${dest}.new`;
194
+ const data = Buffer.from(rendered.join('\n\n') + '\n', 'utf8');
195
+ const prev = current(newRel);
196
+ if (!prev || !prev.equals(data)) plan(newRel, data);
197
+ }
198
+
199
+ const commands = selected.flatMap((u) => u.runs);
200
+ /** @type {string[]} */
201
+ const nextSteps = [];
202
+ for (const unit of selected.filter((u) => u.type === 'preset')) {
203
+ const doc = `docs/agent-kit/${unit.name}.md`;
204
+ if (unit.entries.some((e) => e.dest === doc)) nextSteps.push(doc);
205
+ }
206
+
207
+ if (apply) {
208
+ for (const w of writes) writeTargetFile(root, w.rel, w.data, { mode: w.mode });
209
+ }
210
+
211
+ const conflicts = actions.filter((a) => a.op.startsWith('conflict')).length;
212
+ return {
213
+ root,
214
+ actions,
215
+ conflicts,
216
+ notes: [...new Set(notes)],
217
+ nextSteps,
218
+ commands,
219
+ applied: apply,
220
+ writes: writes.length,
221
+ packs: [...selectedPacks],
222
+ };
223
+ }