@thesmurph/agentlink 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.
@@ -0,0 +1,187 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { writeFileAtomic } from "./link.js";
4
+ export const BEGIN_MARKER = "<!-- agentlink:begin v1 -->";
5
+ export const END_MARKER = "<!-- agentlink:end -->";
6
+ /** Whole-line markers only: prose mentioning the marker is not the marker. */
7
+ const BEGIN_LINE = /^<!-- agentlink:begin v1 -->[ \t]*$/m;
8
+ const END_LINE = /^<!-- agentlink:end -->[ \t]*$/m;
9
+ const PROJECT_CLAUSE = `${BEGIN_MARKER}
10
+ ## Agent docs and skills: one source of truth
11
+
12
+ This repository keeps exactly one copy of every agent instruction file and skill.
13
+ Paths such as \`CLAUDE.md\`, \`GEMINI.md\`, \`.claude/skills/\` and \`.cursor/skills/\`
14
+ are symlinks maintained by \`agentlink\`. Never edit a symlink, and never create a
15
+ file next to one — edit or create the source it points to.
16
+
17
+ - **Instructions** live in \`AGENTS.md\` at the repository root.
18
+ - **Skills** live in \`.agents/skills/<skill-name>/SKILL.md\`, one directory per skill.
19
+ - **Extra documentation** goes under \`.agents/\` (for example \`.agents/testing.md\`),
20
+ or in an \`AGENTS.md\` in the subdirectory it applies to.
21
+ - **Naming**: the skill directory and its frontmatter \`name\` are the same
22
+ lowercase-hyphenated string, 1-64 characters (\`pdf-forms\`, not \`PDF_Forms\`).
23
+ \`SKILL.md\` needs frontmatter with \`name\` and a \`description\` that states what
24
+ the skill does *and* when to use it. Keep scripts and references inside the
25
+ skill directory and link them with relative paths.
26
+ - **After adding, renaming, or moving a skill or doc**, run \`agentlink sync\`
27
+ so every harness picks up the change.
28
+ ${END_MARKER}`;
29
+ const GLOBAL_CLAUSE = `${BEGIN_MARKER}
30
+ ## Personal agent setup
31
+
32
+ This is your user-level instructions file, loaded by every session regardless of
33
+ project. Personal skills live once, in \`~/.agents/skills/<skill-name>/SKILL.md\`.
34
+ Paths such as \`~/.claude/CLAUDE.md\` and \`~/.claude/skills/\` are symlinks
35
+ maintained by \`agentlink\`; never edit a symlink, and never create a file or
36
+ skill directory next to one.
37
+
38
+ A project that carries its own \`AGENTS.md\` and \`.agents/skills/\` takes
39
+ precedence over anything here, so keep this file to preferences that apply
40
+ everywhere: how you like work done, what to ask before doing, personal tooling.
41
+
42
+ - **Naming**: skill directory and frontmatter \`name\` are the same
43
+ lowercase-hyphenated string, 1-64 characters.
44
+ - **After adding, renaming, or moving a personal skill**, run
45
+ \`agentlink sync --global\`.
46
+ ${END_MARKER}`;
47
+ /**
48
+ * The clause agentlink appends to AGENTS.md.
49
+ *
50
+ * Its job is to tell a *future agent* — one that has never heard of agentlink —
51
+ * where documentation and skills belong and how to name them, so the convention
52
+ * survives without a human enforcing it. The text differs by scope because a
53
+ * global file is loaded in every project, including ones that do not use this
54
+ * convention, so it must not claim anything about "this repository".
55
+ */
56
+ export function clauseFor(scope) {
57
+ return scope === "global" ? GLOBAL_CLAUSE : PROJECT_CLAUSE;
58
+ }
59
+ /** The repository-scope clause, kept as a named export for documentation. */
60
+ export const CLAUSE = PROJECT_CLAUSE;
61
+ export function hasClause(text) {
62
+ return BEGIN_LINE.test(text);
63
+ }
64
+ /**
65
+ * Insert the clause, or replace the existing one in place.
66
+ *
67
+ * A lone marker is left untouched: the text after an unterminated block might be
68
+ * the user's own prose, and guessing would delete it.
69
+ */
70
+ export function upsertClause(text, scope = "project") {
71
+ const clause = clauseFor(scope);
72
+ const begin = BEGIN_LINE.exec(text);
73
+ const end = END_LINE.exec(text);
74
+ if (begin && !end)
75
+ return { text, changed: false, action: "malformed" };
76
+ if (!begin && end)
77
+ return { text, changed: false, action: "malformed" };
78
+ if (begin && end) {
79
+ const endIndex = end.index;
80
+ const existing = text.slice(begin.index, endIndex + end[0].length);
81
+ if (existing === clause)
82
+ return { text, changed: false, action: "unchanged" };
83
+ const next = `${text.slice(0, begin.index)}${clause}${text.slice(endIndex + end[0].length)}`;
84
+ return { text: next, changed: true, action: "updated" };
85
+ }
86
+ const trimmed = text.replace(/\s+$/, "");
87
+ const separator = trimmed.length === 0 ? "" : "\n\n";
88
+ return { text: `${trimmed}${separator}${clause}\n`, changed: true, action: "inserted" };
89
+ }
90
+ export function ensureClause(paths, options = {}) {
91
+ const file = paths.instructions;
92
+ if (!existsSync(file))
93
+ return { changed: false, action: "unchanged", file };
94
+ const before = readFileSync(file, "utf8");
95
+ const { text, changed, action } = upsertClause(before, paths.scope);
96
+ if (changed && !options.dryRun)
97
+ writeFileAtomic(file, text);
98
+ return { changed, action, file };
99
+ }
100
+ /** Create AGENTS.md and .agents/skills/ when they do not exist yet. */
101
+ export function initConvention(paths, options = {}) {
102
+ const createdFile = !existsSync(paths.instructions);
103
+ if (createdFile && !options.dryRun) {
104
+ const title = path.basename(paths.root) || "project";
105
+ const heading = paths.scope === "global" ? "Global agent instructions" : title;
106
+ const body = paths.scope === "global"
107
+ ? "<!-- Preferences that apply in every project. Project AGENTS.md files take precedence. -->"
108
+ : "<!-- One or two sentences: what this is, who it is for. -->";
109
+ writeFileAtomic(paths.instructions, `# ${heading}\n\n${body}\n\n${clauseFor(paths.scope)}\n`);
110
+ }
111
+ else if (!options.dryRun) {
112
+ ensureClause(paths, options);
113
+ }
114
+ const createdSkillsDir = !existsSync(paths.skills);
115
+ if (createdSkillsDir && !options.dryRun)
116
+ mkdirSync(paths.skills, { recursive: true });
117
+ return {
118
+ createdFile,
119
+ createdSkillsDir,
120
+ file: paths.instructions,
121
+ skills: paths.skills,
122
+ };
123
+ }
124
+ /**
125
+ * Migrate a harness instructions file into the canonical AGENTS.md.
126
+ *
127
+ * Only the unambiguous case is handled: AGENTS.md is absent and exactly one real
128
+ * (non-symlink) harness file exists. A rename keeps `git log --follow` intact and
129
+ * leaves the old path as a symlink afterwards. When both files exist as real
130
+ * content, merging is a judgement call and we refuse.
131
+ */
132
+ export function adoptInstructions(paths, options = {}) {
133
+ const candidates = ["CLAUDE.md", "GEMINI.md", "QWEN.md", "CRUSH.md", "WARP.md", "CONTEXT.md"];
134
+ const present = candidates
135
+ .map((name) => path.join(paths.root, name))
136
+ .filter((file) => existsSync(file) && !isSymlink(file));
137
+ if (existsSync(paths.instructions)) {
138
+ if (isSymlink(paths.instructions)) {
139
+ // AGENTS.md is itself an alias, so something else holds the real content.
140
+ const link = isSymlink(paths.instructions) ? targetsOf(paths.instructions) : undefined;
141
+ return {
142
+ performed: false,
143
+ needsInvert: true,
144
+ reason: `AGENTS.md is a symlink${link ? ` to ${link}` : ""} — make AGENTS.md the real file and link the other name to it`,
145
+ };
146
+ }
147
+ if (present.length > 0) {
148
+ return {
149
+ performed: false,
150
+ reason: `AGENTS.md and ${present.map((p) => path.basename(p)).join(", ")} both contain real content — merge them by hand, then re-run`,
151
+ };
152
+ }
153
+ return { performed: false, reason: "AGENTS.md already exists" };
154
+ }
155
+ if (present.length === 0)
156
+ return { performed: false, reason: "no harness instructions file to adopt" };
157
+ if (present.length > 1) {
158
+ const names = present.map((p) => path.basename(p));
159
+ return {
160
+ performed: false,
161
+ reason: `several candidates (${names.join(", ")}) — keep one, delete or merge the others, then re-run`,
162
+ };
163
+ }
164
+ const from = present[0];
165
+ if (options.dryRun) {
166
+ return { performed: false, reason: "dry run", from, to: paths.instructions };
167
+ }
168
+ // A rename keeps the original bytes and shows up as a rename in git.
169
+ renameSync(from, paths.instructions);
170
+ return { performed: true, from, to: paths.instructions };
171
+ }
172
+ function isSymlink(file) {
173
+ try {
174
+ return lstatSync(file).isSymbolicLink();
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ }
180
+ function targetsOf(file) {
181
+ try {
182
+ return readlinkSync(file);
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,37 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { HARNESSES } from "./harnesses.js";
5
+ /** Look the binary up in PATH without shelling out. */
6
+ export function isOnPath(bins) {
7
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
8
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE").split(";") : [""];
9
+ for (const bin of bins) {
10
+ for (const dir of dirs) {
11
+ for (const ext of exts) {
12
+ const candidate = path.join(dir, bin + ext);
13
+ if (existsSync(candidate))
14
+ return candidate;
15
+ }
16
+ }
17
+ }
18
+ return undefined;
19
+ }
20
+ /**
21
+ * A harness counts as present when its binary is on PATH or its config
22
+ * directory exists. Config roots come from `herdr integration status`, which
23
+ * knows where each harness actually keeps its state.
24
+ */
25
+ export function detect(harness, home = homedir()) {
26
+ const reasons = [];
27
+ const bin = isOnPath(harness.bins);
28
+ if (bin)
29
+ reasons.push(`binary ${path.basename(bin)}`);
30
+ const configPath = path.join(home, harness.configRoot);
31
+ if (existsSync(configPath))
32
+ reasons.push(`config ${harness.configRoot.startsWith(".") ? "~/" : ""}${harness.configRoot}`);
33
+ return { harness, installed: reasons.length > 0, reasons };
34
+ }
35
+ export function detectAll(home = homedir()) {
36
+ return HARNESSES.map((harness) => detect(harness, home));
37
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,263 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { clauseFor, hasClause, BEGIN_MARKER } from "./convention.js";
4
+ import { unverifiedEndpoints } from "./harnesses.js";
5
+ import { ignoreEntries, isIgnoreMode, readIgnoreBlock } from "./ignore.js";
6
+ import { inspect, plan, readState } from "./link.js";
7
+ import { listHiddenSubdirectories, listSubdirectories } from "./scope.js";
8
+ export function readSkills(paths) {
9
+ return listSubdirectories(paths.skills).map((name) => {
10
+ const dir = path.join(paths.skills, name);
11
+ const direct = path.join(dir, "SKILL.md");
12
+ const hasDirectSkillFile = existsSync(direct);
13
+ return {
14
+ name,
15
+ dir,
16
+ hasDirectSkillFile,
17
+ hasNestedSkillFile: !hasDirectSkillFile && findNestedSkillFile(dir, 1) !== undefined,
18
+ frontmatter: hasDirectSkillFile ? parseFrontmatter(readFileSync(direct, "utf8")) : {},
19
+ };
20
+ });
21
+ }
22
+ /** A SKILL.md in a subdirectory, which only some harnesses read. */
23
+ export function findNestedSkillFile(dir, depth = 0) {
24
+ const direct = path.join(dir, "SKILL.md");
25
+ if (existsSync(direct))
26
+ return direct;
27
+ if (depth >= 2)
28
+ return undefined;
29
+ for (const child of listSubdirectories(dir)) {
30
+ const found = findNestedSkillFile(path.join(dir, child), depth + 1);
31
+ if (found)
32
+ return found;
33
+ }
34
+ return undefined;
35
+ }
36
+ export function parseFrontmatter(text) {
37
+ if (!text.startsWith("---"))
38
+ return {};
39
+ const end = text.indexOf("\n---", 3);
40
+ if (end === -1)
41
+ return {};
42
+ const block = text.slice(3, end);
43
+ const read = (key) => {
44
+ const match = block.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
45
+ if (!match?.[1])
46
+ return undefined;
47
+ return match[1].trim().replace(/^["']|["']$/g, "");
48
+ };
49
+ return { name: read("name"), description: read("description") };
50
+ }
51
+ /**
52
+ * Report the problems a user can act on.
53
+ *
54
+ * Missing or misdirected links for a selected harness are errors, not warnings:
55
+ * the whole promise of the tool is that the harness reads what you wrote, and a
56
+ * repository that fails that should not pass a CI check.
57
+ */
58
+ export function diagnose({ paths, harnesses }) {
59
+ const findings = [];
60
+ // With nothing selected there are no links to check. Say so, rather than
61
+ // reporting a clean bill of health that was never actually earned: a CI
62
+ // runner has no harnesses installed, so this is reachable on every run.
63
+ if (harnesses.length === 0) {
64
+ findings.push({
65
+ severity: "warn",
66
+ message: "no harnesses are selected or installed, so only the convention was checked",
67
+ fix: "agentlink init, or pass --harnesses to check a specific set",
68
+ });
69
+ }
70
+ // --- the convention itself ------------------------------------------------
71
+ const instructionsExist = existsSync(paths.instructions);
72
+ if (!instructionsExist) {
73
+ findings.push({
74
+ severity: "error",
75
+ message: `${label(paths, paths.instructions)} is missing`,
76
+ fix: "agentlink init",
77
+ });
78
+ }
79
+ else {
80
+ const text = readFileSync(paths.instructions, "utf8");
81
+ if (!hasClause(text)) {
82
+ findings.push({
83
+ severity: "warn",
84
+ message: `${path.basename(paths.instructions)} does not explain the convention to agents`,
85
+ fix: "agentlink sync (appends the agentlink clause)",
86
+ });
87
+ }
88
+ else if (!text.includes(clauseFor(paths.scope))) {
89
+ findings.push({
90
+ severity: "info",
91
+ message: `${path.basename(paths.instructions)} has an outdated agentlink clause, or an unterminated one`,
92
+ fix: "agentlink sync, then check the section by hand",
93
+ });
94
+ }
95
+ }
96
+ if (!existsSync(paths.skills)) {
97
+ findings.push({
98
+ severity: "warn",
99
+ message: `${short(paths, paths.skills)} does not exist`,
100
+ fix: "agentlink init",
101
+ });
102
+ }
103
+ // --- skills ---------------------------------------------------------------
104
+ for (const file of safeReadDir(paths.skills).filter((name) => name.endsWith(".md"))) {
105
+ findings.push({
106
+ severity: "warn",
107
+ message: `${short(paths, path.join(paths.skills, file))} sits at the root of .agents/skills`,
108
+ fix: "move it into .agents/skills/<skill-name>/SKILL.md — root Markdown is not a skill",
109
+ });
110
+ }
111
+ for (const hidden of listHiddenSubdirectories(paths.skills)) {
112
+ findings.push({
113
+ severity: "info",
114
+ message: `.agents/skills/${hidden} starts with a dot, so it is skipped`,
115
+ fix: "rename it without the dot to publish it as a skill",
116
+ });
117
+ }
118
+ for (const name of safeReadDir(paths.skills)) {
119
+ const entry = path.join(paths.skills, name);
120
+ if (name.startsWith(".") || name.endsWith(".md"))
121
+ continue;
122
+ const kind = inspect(entry).kind;
123
+ if (kind === "symlink") {
124
+ const target = inspect(entry).resolved;
125
+ if (!target || !existsSync(target)) {
126
+ findings.push({
127
+ severity: "error",
128
+ message: `.agents/skills/${name} is a broken symlink`,
129
+ fix: "remove it or point it at a directory that exists",
130
+ });
131
+ }
132
+ continue;
133
+ }
134
+ if (kind === "error") {
135
+ findings.push({
136
+ severity: "error",
137
+ message: `.agents/skills/${name} cannot be read`,
138
+ fix: "check permissions on the path",
139
+ });
140
+ }
141
+ }
142
+ for (const skill of readSkills(paths)) {
143
+ if (!skill.hasDirectSkillFile) {
144
+ findings.push({
145
+ severity: "error",
146
+ message: skill.hasNestedSkillFile
147
+ ? `.agents/skills/${skill.name} has no SKILL.md directly inside, only nested ones — most harnesses will not find it`
148
+ : `.agents/skills/${skill.name} has no SKILL.md`,
149
+ fix: "put SKILL.md at .agents/skills/<skill-name>/SKILL.md",
150
+ });
151
+ continue;
152
+ }
153
+ const name = skill.frontmatter.name;
154
+ if (!name) {
155
+ findings.push({
156
+ severity: "error",
157
+ message: `.agents/skills/${skill.name}/SKILL.md has no \`name\` frontmatter`,
158
+ fix: "add `name:` matching the directory name",
159
+ });
160
+ continue;
161
+ }
162
+ if (name !== skill.name) {
163
+ findings.push({
164
+ severity: "warn",
165
+ message: `.agents/skills/${skill.name} declares name \`${name}\``,
166
+ fix: "keep the directory name and frontmatter name identical so every harness resolves it",
167
+ });
168
+ }
169
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) || name.length > 64) {
170
+ findings.push({
171
+ severity: "error",
172
+ message: `skill name \`${name}\` is not lowercase-hyphenated, 1-64 chars`,
173
+ });
174
+ }
175
+ if (!skill.frontmatter.description) {
176
+ findings.push({
177
+ severity: "error",
178
+ message: `.agents/skills/${skill.name}/SKILL.md has no \`description\``,
179
+ fix: "describe what the skill does and when to use it",
180
+ });
181
+ }
182
+ }
183
+ // --- links ----------------------------------------------------------------
184
+ const desired = plan(paths, harnesses);
185
+ for (const op of desired.ops) {
186
+ const existing = inspect(op.target);
187
+ if (existing.kind === "missing") {
188
+ findings.push({
189
+ severity: "error",
190
+ message: `${short(paths, op.target)} is not linked (${op.harnessIds.join(", ")})`,
191
+ fix: "agentlink sync",
192
+ });
193
+ }
194
+ else if (existing.kind === "symlink" && existing.resolved !== op.source) {
195
+ findings.push({
196
+ severity: "error",
197
+ message: `${short(paths, op.target)} points at ${short(paths, existing.resolved ?? "")}`,
198
+ fix: "agentlink sync",
199
+ });
200
+ }
201
+ else if (existing.kind === "file" || existing.kind === "dir") {
202
+ if (op.kind === "instructions") {
203
+ findings.push({
204
+ severity: "error",
205
+ message: `${short(paths, op.target)} is a real ${existing.kind}, not a symlink — two copies of your instructions`,
206
+ fix: "agentlink adopt (moves it to AGENTS.md and links back)",
207
+ });
208
+ }
209
+ else {
210
+ // Reported even when empty: an empty directory still blocks the link.
211
+ findings.push({
212
+ severity: "error",
213
+ message: `${short(paths, op.target)} is a real ${existing.kind} instead of a link to skill \`${op.skill}\``,
214
+ fix: `agentlink fix (the canonical copy is .agents/skills/${op.skill})`,
215
+ });
216
+ }
217
+ }
218
+ }
219
+ // --- honesty about the table ---------------------------------------------
220
+ for (const harness of harnesses) {
221
+ for (const { kind, endpoint } of unverifiedEndpoints(harness, paths.scope)) {
222
+ const where = endpoint.native ? "native path" : endpoint.alias;
223
+ findings.push({
224
+ severity: "info",
225
+ message: `${harness.label}: ${kind} ${where} is not confirmed by ${harness.source}`,
226
+ });
227
+ }
228
+ }
229
+ // --- gitignore ------------------------------------------------------------
230
+ const state = readState(paths);
231
+ const mode = isIgnoreMode(state.ignore) ? state.ignore : "skills";
232
+ if (paths.scope === "project" && existsSync(path.join(paths.root, ".git")) && mode !== "none") {
233
+ const expected = ignoreEntries(mode, {
234
+ skillDirs: [...new Set(desired.ops.filter((op) => op.kind === "skill").map((op) => path.posix.dirname(op.rel)))],
235
+ instructionFiles: [...new Set(desired.ops.filter((op) => op.kind === "instructions").map((op) => op.rel))],
236
+ });
237
+ const actual = readIgnoreBlock(paths);
238
+ const missing = expected.filter((entry) => !actual.includes(entry));
239
+ if (missing.length > 0) {
240
+ findings.push({
241
+ severity: "warn",
242
+ message: `${missing.length} linked path${missing.length === 1 ? "" : "s"} would show up as untracked: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ", …" : ""}`,
243
+ fix: "agentlink sync",
244
+ });
245
+ }
246
+ }
247
+ return findings;
248
+ }
249
+ function safeReadDir(dir) {
250
+ try {
251
+ return readdirSync(dir);
252
+ }
253
+ catch {
254
+ return [];
255
+ }
256
+ }
257
+ function short(paths, absolute) {
258
+ return path.relative(paths.root, absolute) || ".";
259
+ }
260
+ function label(paths, absolute) {
261
+ return path.join(paths.scope === "global" ? "~" : ".", short(paths, absolute));
262
+ }
263
+ export { statSync, BEGIN_MARKER };
package/dist/fix.js ADDED
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, } from "node:fs";
3
+ import path from "node:path";
4
+ import { inspect, isOwned, isInside, pruneEmptyParents } from "./link.js";
5
+ import { listSubdirectories, relativeTo } from "./scope.js";
6
+ /**
7
+ * Find real copies sitting where a symlink belongs.
8
+ *
9
+ * A copy is cheap to resolve when the canonical tree has nothing at that name
10
+ * (move it in) or has an identical directory (delete the copy). When the two
11
+ * differ, nothing is guessed: the caller reports both paths and stops, unless
12
+ * the user explicitly asks for the canonical copy to win.
13
+ */
14
+ export function planFixes(paths, harnesses) {
15
+ const actions = [];
16
+ const seen = new Set();
17
+ for (const harness of harnesses) {
18
+ const skills = harness.skills[paths.scope];
19
+ if (!skills.native && skills.alias) {
20
+ const dir = path.join(paths.root, skills.alias);
21
+ // A harness directory that is itself a symlink into the canonical tree
22
+ // would make every entry look like an identical duplicate of itself.
23
+ // Acting on that would delete the canonical skills.
24
+ if (isInsideCanonical(paths, dir))
25
+ continue;
26
+ for (const name of listSubdirectories(dir)) {
27
+ const target = path.join(dir, name);
28
+ const rel = relativeTo(paths.root, target);
29
+ if (seen.has(rel))
30
+ continue;
31
+ seen.add(rel);
32
+ // Only real directories are copies. Symlinks are already correct, and
33
+ // a symlinked directory is resolved through, never replaced.
34
+ if (inspect(target).kind !== "dir")
35
+ continue;
36
+ if (isInsideCanonical(paths, target))
37
+ continue;
38
+ const source = path.join(paths.skills, name);
39
+ const canonical = relativeTo(paths.root, source);
40
+ if (!existsSync(source)) {
41
+ actions.push({ target: rel, canonical, kind: "move", skill: name });
42
+ }
43
+ else if (hashTree(target) === hashTree(source)) {
44
+ actions.push({ target: rel, canonical, kind: "remove-identical", skill: name });
45
+ }
46
+ else {
47
+ actions.push({ target: rel, canonical, kind: "conflict", skill: name, detail: "both copies differ" });
48
+ }
49
+ }
50
+ }
51
+ // Instructions: a real file or directory where a symlink belongs needs a human.
52
+ const instructions = harness.instructions[paths.scope];
53
+ if (!instructions.native && instructions.alias && existsSync(paths.instructions)) {
54
+ const target = path.join(paths.root, instructions.alias);
55
+ const rel = relativeTo(paths.root, target);
56
+ const kind = inspect(target).kind;
57
+ if (!seen.has(rel) && (kind === "file" || kind === "dir")) {
58
+ seen.add(rel);
59
+ actions.push({
60
+ target: rel,
61
+ canonical: relativeTo(paths.root, paths.instructions),
62
+ kind: "conflict",
63
+ detail: "two copies of your instructions",
64
+ });
65
+ }
66
+ }
67
+ }
68
+ return actions;
69
+ }
70
+ /** True when `candidate` is the canonical skills tree, or inside it. */
71
+ function isInsideCanonical(paths, candidate) {
72
+ if (!existsSync(candidate))
73
+ return false;
74
+ let real;
75
+ try {
76
+ real = realpathSync(candidate);
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ let skillsReal;
82
+ try {
83
+ skillsReal = realpathSync(paths.skills);
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ if (isInside(skillsReal, real))
89
+ return true;
90
+ try {
91
+ // Resolving the parent catches a canonical entry that is itself a symlink
92
+ // into .agents, which realpath already collapsed above.
93
+ return isInside(realpathSync(paths.agentsDir), real);
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ export function applyFixes(paths, actions, options = {}) {
100
+ return actions.map((action) => {
101
+ if (action.kind === "conflict" && !options.force)
102
+ return { ...action, performed: false };
103
+ const target = path.join(paths.root, action.target);
104
+ const canonical = path.join(paths.root, action.canonical);
105
+ // Never touch anything that is not inside the scope root, and never touch
106
+ // the canonical tree itself.
107
+ if (!isInside(paths.root, target) || isOwned(paths, target)) {
108
+ return { ...action, performed: false, detail: "refused: outside the scope root" };
109
+ }
110
+ if (options.dryRun)
111
+ return { ...action, performed: false };
112
+ try {
113
+ if (action.kind === "move") {
114
+ mkdirSync(path.dirname(canonical), { recursive: true });
115
+ renameSync(target, canonical);
116
+ }
117
+ else {
118
+ rmSync(target, { recursive: true, force: true });
119
+ }
120
+ pruneEmptyParents(path.dirname(target), paths.root);
121
+ return { ...action, performed: true };
122
+ }
123
+ catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ return { ...action, performed: false, detail: `failed: ${message}` };
126
+ }
127
+ });
128
+ }
129
+ /** Stable hash of a directory tree, tolerant of line-ending differences. */
130
+ export function hashTree(dir) {
131
+ const hash = createHash("sha256");
132
+ walk(dir, "", hash);
133
+ return hash.digest("hex");
134
+ }
135
+ function walk(current, prefix, hash) {
136
+ let entries;
137
+ try {
138
+ entries = readdirSync(current, { withFileTypes: true });
139
+ }
140
+ catch (error) {
141
+ // An unreadable directory must not look like an empty one.
142
+ hash.update(`unreadable ${prefix} ${error?.code ?? "?"}\n`);
143
+ return;
144
+ }
145
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
146
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
147
+ const full = path.join(current, entry.name);
148
+ if (entry.isSymbolicLink()) {
149
+ hash.update(`link ${rel} ${safeReadlink(full)}\n`);
150
+ }
151
+ else if (entry.isDirectory()) {
152
+ hash.update(`dir ${rel}\n`);
153
+ walk(full, rel, hash);
154
+ }
155
+ else {
156
+ hash.update(`file ${rel} ${hashFile(full)}\n`);
157
+ }
158
+ }
159
+ }
160
+ function hashFile(file) {
161
+ try {
162
+ const buffer = readFileSync(file);
163
+ // Treat CRLF and LF files as equal so a Windows-authored copy is not
164
+ // reported as a conflict.
165
+ const isBinary = buffer.includes(0);
166
+ const content = isBinary ? buffer : Buffer.from(buffer.toString("utf8").replace(/\r\n/g, "\n"));
167
+ return createHash("sha256").update(content).digest("hex");
168
+ }
169
+ catch (error) {
170
+ // A constant sentinel would make two different unreadable files compare
171
+ // equal, which would pick "identical" and delete one of them.
172
+ const code = error?.code ?? "?";
173
+ return `unreadable:${code}:${Buffer.from(file).toString("base64")}`;
174
+ }
175
+ }
176
+ function safeReadlink(file) {
177
+ try {
178
+ return readlinkSync(file);
179
+ }
180
+ catch {
181
+ return "?";
182
+ }
183
+ }