@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,206 @@
1
+ export function endpointVerified(endpoint) {
2
+ return endpoint.verified !== false;
3
+ }
4
+ /** Endpoints in a scope that no vendor documentation confirms. */
5
+ export function unverifiedEndpoints(harness, scope) {
6
+ const found = [];
7
+ for (const kind of ["instructions", "skills"]) {
8
+ const endpoint = harness[kind][scope];
9
+ if (!endpointVerified(endpoint))
10
+ found.push({ kind, endpoint });
11
+ }
12
+ return found;
13
+ }
14
+ const PI_SKILLS_DOC = "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md";
15
+ const CODEX_SKILLS_DOC = "https://developers.openai.com/codex/skills";
16
+ const CLAUDE_SKILLS_DOC = "https://code.claude.com/docs/en/skills";
17
+ const COPILOT_SKILLS_DOC = "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills";
18
+ const COPILOT_INSTRUCTIONS_DOC = "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions";
19
+ /** Build a endpoints pair: project + global in one call. */
20
+ function scopes(project, global) {
21
+ return { project, global };
22
+ }
23
+ const NATIVE = { native: true };
24
+ export const HARNESSES = [
25
+ {
26
+ id: "claude",
27
+ label: "Claude Code",
28
+ bins: ["claude"],
29
+ configRoot: ".claude",
30
+ // Claude Code reads CLAUDE.md, not AGENTS.md, and never scans .agents/skills.
31
+ instructions: scopes({ native: false, alias: "CLAUDE.md", note: "Claude Code has no AGENTS.md fallback" }, { native: false, alias: ".claude/CLAUDE.md" }),
32
+ skills: scopes({ native: false, alias: ".claude/skills" }, { native: false, alias: ".claude/skills" }),
33
+ source: CLAUDE_SKILLS_DOC,
34
+ },
35
+ {
36
+ id: "codex",
37
+ label: "OpenAI Codex CLI",
38
+ bins: ["codex"],
39
+ configRoot: ".codex",
40
+ // Codex reads AGENTS.md in the repo and ~/.codex/AGENTS.md globally.
41
+ instructions: scopes({ native: true }, { native: false, alias: ".codex/AGENTS.md" }),
42
+ // Codex's user-level skills directory is $HOME/.agents/skills: native both ways.
43
+ skills: scopes(NATIVE, NATIVE),
44
+ source: CODEX_SKILLS_DOC,
45
+ },
46
+ {
47
+ id: "pi",
48
+ label: "Pi",
49
+ bins: ["pi"],
50
+ configRoot: ".pi",
51
+ instructions: scopes({ native: true }, { native: false, alias: ".pi/agent/AGENTS.md" }),
52
+ // Pi reads ~/.agents/skills and .agents/skills directly.
53
+ skills: scopes(NATIVE, NATIVE),
54
+ source: PI_SKILLS_DOC,
55
+ },
56
+ {
57
+ id: "omp",
58
+ label: "Oh My Pi (omp)",
59
+ bins: ["omp"],
60
+ configRoot: ".omp",
61
+ instructions: scopes({ native: true, note: "standalone AGENTS.md via the agents-md provider" }, { native: false, alias: ".omp/agent/AGENTS.md" }),
62
+ // omp scans its own .omp/skills trees; it does not read .agents/skills.
63
+ skills: scopes({ native: false, alias: ".omp/skills" }, { native: false, alias: ".omp/agent/skills" }),
64
+ source: "https://github.com/can1357/oh-my-pi/blob/main/docs/config-usage.md",
65
+ },
66
+ {
67
+ id: "copilot",
68
+ label: "GitHub Copilot CLI",
69
+ bins: ["copilot"],
70
+ configRoot: ".copilot",
71
+ // Copilot CLI reads AGENTS.md everywhere, but its *user* instructions live
72
+ // in a differently named file.
73
+ instructions: scopes({ native: true }, { native: false, alias: ".copilot/copilot-instructions.md" }),
74
+ skills: scopes(NATIVE, NATIVE),
75
+ source: `${COPILOT_SKILLS_DOC} · ${COPILOT_INSTRUCTIONS_DOC}`,
76
+ },
77
+ {
78
+ id: "cursor",
79
+ label: "Cursor",
80
+ bins: ["cursor-agent", "cursor"],
81
+ configRoot: ".cursor",
82
+ instructions: scopes({ native: true, note: "Cursor reads AGENTS.md" }, { native: false, alias: ".cursor/AGENTS.md", note: "not documented by Cursor", verified: false }),
83
+ skills: scopes({ native: false, alias: ".cursor/skills", verified: false }, { native: false, alias: ".cursor/skills", verified: false }),
84
+ source: "https://cursor.com/docs/agent/context",
85
+ },
86
+ {
87
+ id: "opencode",
88
+ label: "opencode",
89
+ bins: ["opencode"],
90
+ configRoot: ".config/opencode",
91
+ instructions: scopes({ native: true }, { native: false, alias: ".config/opencode/AGENTS.md" }),
92
+ skills: scopes({ native: false, alias: ".opencode/skills", verified: false }, { native: false, alias: ".config/opencode/skills", verified: false }),
93
+ source: "https://opencode.ai/docs/rules/",
94
+ },
95
+ {
96
+ id: "qwen",
97
+ label: "Qwen Code",
98
+ bins: ["qwen"],
99
+ configRoot: ".qwen",
100
+ instructions: scopes({ native: true, note: "QWEN.md is the legacy name" }, { native: false, alias: ".qwen/AGENTS.md", note: "not documented by Qwen", verified: false }),
101
+ skills: scopes({ native: false, alias: ".qwen/skills" }, { native: false, alias: ".qwen/skills" }),
102
+ source: "https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/",
103
+ },
104
+ {
105
+ id: "kimi",
106
+ label: "Kimi Code CLI",
107
+ bins: ["kimi"],
108
+ configRoot: ".kimi-code",
109
+ instructions: scopes({ native: true }, { native: false, alias: ".kimi-code/AGENTS.md", note: "not documented by Kimi", verified: false }),
110
+ skills: scopes({ native: false, alias: ".kimi-code/skills" }, NATIVE),
111
+ source: "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/skills.html",
112
+ },
113
+ {
114
+ id: "kilo",
115
+ label: "Kilo Code",
116
+ bins: ["kilo"],
117
+ configRoot: ".config/kilo",
118
+ instructions: scopes({ native: true }, { native: false, alias: ".config/kilo/AGENTS.md", note: "not documented by Kilo" }),
119
+ // Kilo Code reads Claude Code's skills directory for compatibility.
120
+ skills: scopes({ native: false, alias: ".claude/skills", note: "shares Claude Code's directory", verified: false }, { native: false, alias: ".config/kilo/skills", note: "unconfirmed", verified: false }),
121
+ source: "https://github.com/intellectronica/ruler#skills-support-experimental",
122
+ },
123
+ {
124
+ id: "droid",
125
+ label: "Factory Droid",
126
+ bins: ["droid"],
127
+ configRoot: ".factory",
128
+ instructions: scopes({ native: true, note: "AGENTS.md may also live in the home directory" }, { native: false, alias: ".factory/AGENTS.md" }),
129
+ skills: scopes({ native: false, alias: ".factory/skills" }, { native: false, alias: ".factory/skills" }),
130
+ source: "https://docs.factory.ai/harness/skills",
131
+ },
132
+ {
133
+ id: "devin",
134
+ label: "Devin CLI",
135
+ bins: ["devin"],
136
+ configRoot: ".config/devin",
137
+ instructions: scopes({ native: true }, { native: false, alias: ".config/devin/AGENTS.md" }),
138
+ skills: scopes({ native: false, alias: ".devin/skills" }, { native: false, alias: ".config/devin/skills" }),
139
+ source: "https://docs.devin.ai/cli/extensibility/rules",
140
+ },
141
+ {
142
+ id: "mastracode",
143
+ label: "Mastra Code",
144
+ bins: ["mastracode"],
145
+ configRoot: ".mastracode",
146
+ instructions: scopes({ native: true }, { native: false, alias: ".mastracode/AGENTS.md", note: "not documented by Mastra", verified: false }),
147
+ // Mastra Code lists .agents/skills as a project source and Agent Skills
148
+ // spec compatibility, so both scopes are native.
149
+ skills: scopes(NATIVE, NATIVE),
150
+ source: "https://code.mastra.ai/configuration",
151
+ },
152
+ {
153
+ id: "grok",
154
+ label: "Grok CLI",
155
+ bins: ["grok"],
156
+ configRoot: ".grok",
157
+ instructions: scopes({ native: true, note: "reads AGENTS.md, CLAUDE.md, AGENT.md" }, { native: false, alias: ".grok/AGENTS.md", note: "not documented by Grok", verified: false }),
158
+ skills: scopes({ native: false, alias: ".grok/skills" }, { native: false, alias: ".grok/skills" }),
159
+ source: "https://docs.x.ai/docs/grok-cli/skills",
160
+ },
161
+ {
162
+ id: "qoder",
163
+ label: "Qoder CLI",
164
+ bins: ["qodercli", "qoder"],
165
+ configRoot: ".qoder",
166
+ instructions: scopes({ native: true, note: "configurable via context.fileName" }, { native: false, alias: ".qoder/AGENTS.md", note: "not documented by Qoder", verified: false }),
167
+ skills: scopes({ native: false, alias: ".qoder/skills" }, { native: false, alias: ".qoder/skills" }),
168
+ source: "https://docs.qoder.com/cli/Skills",
169
+ },
170
+ {
171
+ id: "antigravity",
172
+ label: "Antigravity CLI",
173
+ bins: ["agy", "antigravity"],
174
+ configRoot: ".gemini/config",
175
+ instructions: scopes({ native: true, note: "Gemini-lineage discovery: AGENTS.md, CONTEXT.md, GEMINI.md", verified: false }, { native: false, alias: ".gemini/config/AGENTS.md", note: "unconfirmed", verified: false }),
176
+ skills: scopes({ native: false, alias: ".agent/skills", note: "unconfirmed", verified: false }, { native: false, alias: ".gemini/config/skills", note: "unconfirmed", verified: false }),
177
+ source: "https://github.com/intellectronica/ruler#skills-support-experimental",
178
+ },
179
+ {
180
+ id: "hermes",
181
+ label: "Hermes",
182
+ bins: ["hermes"],
183
+ configRoot: ".hermes",
184
+ instructions: scopes({ native: true, note: "unconfirmed", verified: false }, { native: false, alias: ".hermes/AGENTS.md", note: "unconfirmed" }),
185
+ skills: scopes({ native: false, alias: ".hermes/skills", note: "unconfirmed", verified: false }, { native: false, alias: ".hermes/skills", note: "unconfirmed", verified: false }),
186
+ source: "https://herdr.dev/llms.txt",
187
+ },
188
+ ];
189
+ export const HARNESS_IDS = HARNESSES.map((h) => h.id);
190
+ export function findHarness(id) {
191
+ const needle = id.trim().toLowerCase();
192
+ return HARNESSES.find((h) => h.id === needle || h.label.toLowerCase() === needle);
193
+ }
194
+ /** Resolve a comma/space separated harness list; unknown ids are returned to the caller. */
195
+ export function resolveHarnessList(input) {
196
+ const found = [];
197
+ const unknown = [];
198
+ for (const raw of input.split(/[,\s]+/).filter(Boolean)) {
199
+ const harness = findHarness(raw);
200
+ if (harness)
201
+ found.push(harness);
202
+ else
203
+ unknown.push(raw);
204
+ }
205
+ return { found, unknown };
206
+ }
package/dist/ignore.js ADDED
@@ -0,0 +1,120 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { writeFileAtomic } from "./link.js";
4
+ /** Matched as whole lines: a user comment like "# agentlink:beginning" is not our marker. */
5
+ const BEGIN_LINE = /^# agentlink:begin\s*$/m;
6
+ const END_LINE = /^# agentlink:end\s*$/m;
7
+ const BLOCK = "# agentlink:begin";
8
+ export const IGNORE_MODES = ["skills", "all", "none"];
9
+ export function isIgnoreMode(value) {
10
+ return IGNORE_MODES.includes(value);
11
+ }
12
+ /**
13
+ * Which created links belong in .gitignore.
14
+ *
15
+ * Instructions aliases are one small file per harness and are the thing that
16
+ * makes a fresh clone work for a teammate on a different harness, so they are
17
+ * committed. Skill links are derived, multiply with every skill and harness,
18
+ * and their parent directories also hold machine-local harness state, so they
19
+ * are ignored and regenerated by `agentlink sync`.
20
+ */
21
+ export function ignoreEntries(mode, input) {
22
+ const entries = [];
23
+ if (mode === "none")
24
+ return entries;
25
+ for (const dir of input.skillDirs)
26
+ entries.push(`${dir}/`);
27
+ if (mode === "all")
28
+ entries.push(...input.instructionFiles);
29
+ return [...new Set(entries)].sort();
30
+ }
31
+ export function updateGitignore(paths, mode, input, options = {}) {
32
+ const file = path.join(paths.root, ".gitignore");
33
+ const entries = ignoreEntries(mode, input);
34
+ // Only touch a repository that actually tracks files here.
35
+ if (paths.scope !== "project" || !existsSync(path.join(paths.root, ".git"))) {
36
+ return { status: "unchanged", file, entries, skipped: "not a git repository" };
37
+ }
38
+ const before = existsSync(file) ? readFileSync(file, "utf8") : "";
39
+ const spliced = applyIgnoreBlock(before, entries);
40
+ if (spliced.status === "malformed")
41
+ return { status: spliced.status, file, entries };
42
+ const changed = spliced.status === "updated";
43
+ if (changed && !options.dryRun)
44
+ writeFileAtomic(file, spliced.text);
45
+ return { status: spliced.status, file, entries };
46
+ }
47
+ /** Remove the managed block, leaving the rest of .gitignore alone. */
48
+ export function removeIgnoreBlock(paths, options = {}) {
49
+ const file = path.join(paths.root, ".gitignore");
50
+ if (!existsSync(file))
51
+ return false;
52
+ const before = readFileSync(file, "utf8");
53
+ const spliced = applyIgnoreBlock(before, []);
54
+ if (spliced.status !== "updated" || spliced.text === before)
55
+ return false;
56
+ if (!options.dryRun)
57
+ writeFileAtomic(file, spliced.text);
58
+ return true;
59
+ }
60
+ /** Read the entries currently inside the managed block. */
61
+ export function readIgnoreBlock(paths) {
62
+ const text = existsSync(path.join(paths.root, ".gitignore"))
63
+ ? readFileSync(path.join(paths.root, ".gitignore"), "utf8")
64
+ : "";
65
+ return blockSpan(text)?.entries ?? [];
66
+ }
67
+ /** Locate the managed block by whole-line markers. Returns null when absent. */
68
+ function blockSpan(text) {
69
+ const begin = BEGIN_LINE.exec(text);
70
+ if (!begin)
71
+ return null;
72
+ const afterBegin = begin.index + begin[0].length;
73
+ const end = END_LINE.exec(text.slice(afterBegin));
74
+ if (!end)
75
+ return null;
76
+ const endIndex = afterBegin + end.index;
77
+ const body = text.slice(afterBegin, endIndex);
78
+ return {
79
+ start: begin.index,
80
+ end: endIndex + end[0].length,
81
+ entries: body
82
+ .split("\n")
83
+ .map((line) => line.trim())
84
+ .filter((line) => line.length > 0 && !line.startsWith("#")),
85
+ };
86
+ }
87
+ /**
88
+ * Insert, replace or remove the managed block.
89
+ *
90
+ * Text outside the block is never rewritten: no whitespace normalisation runs
91
+ * over the user's file. A lone marker is refused rather than guessed at, since
92
+ * anything after an unterminated block might be the user's own rules.
93
+ */
94
+ export function applyIgnoreBlock(text, entries) {
95
+ const beginMatch = BEGIN_LINE.exec(text);
96
+ const endMatch = END_LINE.exec(text);
97
+ if (beginMatch && !endMatch) {
98
+ return { text, status: "malformed" };
99
+ }
100
+ if (!beginMatch && endMatch) {
101
+ return { text, status: "malformed" };
102
+ }
103
+ const span = beginMatch && endMatch ? blockSpan(text) : null;
104
+ const without = span ? `${text.slice(0, span.start)}${text.slice(span.end)}` : text;
105
+ if (entries.length === 0) {
106
+ if (!span)
107
+ return { text, status: "unchanged" };
108
+ // Remove the blank line the block was separated by, but nothing else.
109
+ const trimmed = without.replace(/\n[ \t]*\n$/, "\n").replace(/\n$/, text.endsWith("\n") ? "\n" : "");
110
+ return { text: trimmed, status: "updated" };
111
+ }
112
+ const block = [BLOCK, ...entries, "# agentlink:end"].join("\n");
113
+ if (span) {
114
+ const next = `${without.slice(0, span.start)}${block}\n${without.slice(span.start).replace(/^\n/, "")}`;
115
+ return { text: next, status: next === text ? "unchanged" : "updated" };
116
+ }
117
+ const base = text.replace(/\s+$/, "");
118
+ const next = base === "" ? `${block}\n` : `${base}\n\n${block}\n`;
119
+ return { text: next, status: next === text ? "unchanged" : "updated" };
120
+ }
package/dist/link.js ADDED
@@ -0,0 +1,307 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { listSubdirectories, relativeTo } from "./scope.js";
4
+ /** True when `dir` resolves to the same place as the canonical skills tree. */
5
+ function resolvesToCanonicalSkills(paths, dir) {
6
+ try {
7
+ return realpathSync(dir) === realpathSync(paths.skills);
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /** Build the full set of links for a scope without touching the filesystem. */
14
+ export function plan(paths, harnesses) {
15
+ const ops = new Map();
16
+ const skips = [];
17
+ const native = [];
18
+ const unknown = [];
19
+ const aliases = [];
20
+ const add = (op, harnessId) => {
21
+ const existing = ops.get(op.target);
22
+ if (existing) {
23
+ if (!existing.harnessIds.includes(harnessId))
24
+ existing.harnessIds.push(harnessId);
25
+ return;
26
+ }
27
+ ops.set(op.target, { ...op, harnessIds: [harnessId] });
28
+ };
29
+ const skip = (op, harnessId) => {
30
+ const existing = skips.find((s) => s.target === op.target && s.reason === op.reason);
31
+ if (existing) {
32
+ if (!existing.harnessIds.includes(harnessId))
33
+ existing.harnessIds.push(harnessId);
34
+ return;
35
+ }
36
+ skips.push({ ...op, harnessIds: [harnessId] });
37
+ };
38
+ const instructionsExist = existsSync(paths.instructions);
39
+ const skills = listSubdirectories(paths.skills);
40
+ for (const harness of harnesses) {
41
+ const instr = harness.instructions[paths.scope];
42
+ const alias = instr.native ? undefined : instr.alias;
43
+ if (instr.native) {
44
+ native.push({ harnessId: harness.id, kind: "instructions" });
45
+ }
46
+ else if (!alias) {
47
+ unknown.push({ harnessId: harness.id, kind: "instructions" });
48
+ }
49
+ else if (!instructionsExist) {
50
+ skip({
51
+ target: path.join(paths.root, alias),
52
+ rel: alias,
53
+ kind: "instructions",
54
+ reason: "AGENTS.md does not exist yet — run `agentlink init` first",
55
+ }, harness.id);
56
+ }
57
+ else {
58
+ add({
59
+ target: path.join(paths.root, alias),
60
+ source: paths.instructions,
61
+ rel: alias,
62
+ kind: "instructions",
63
+ }, harness.id);
64
+ }
65
+ const sk = harness.skills[paths.scope];
66
+ const skillsAlias = sk.native ? undefined : sk.alias;
67
+ if (sk.native) {
68
+ native.push({ harnessId: harness.id, kind: "skill" });
69
+ }
70
+ else if (!skillsAlias) {
71
+ unknown.push({ harnessId: harness.id, kind: "skill" });
72
+ }
73
+ else if (resolvesToCanonicalSkills(paths, path.join(paths.root, skillsAlias))) {
74
+ // The harness directory is a symlink to .agents/skills, which is already
75
+ // the arrangement this tool exists to create.
76
+ aliases.push({ rel: skillsAlias, harnessId: harness.id });
77
+ }
78
+ else if (skills.length === 0) {
79
+ skip({
80
+ target: path.join(paths.root, skillsAlias),
81
+ rel: skillsAlias,
82
+ kind: "skill",
83
+ reason: "no skills in .agents/skills yet",
84
+ }, harness.id);
85
+ }
86
+ else {
87
+ for (const skill of skills) {
88
+ add({
89
+ target: path.join(paths.root, skillsAlias, skill),
90
+ source: path.join(paths.skills, skill),
91
+ rel: path.posix.join(skillsAlias, skill),
92
+ kind: "skill",
93
+ skill,
94
+ }, harness.id);
95
+ }
96
+ }
97
+ }
98
+ return { ops: [...ops.values()], skips, native, unknown, aliases, skillsFound: skills };
99
+ }
100
+ /**
101
+ * Apply a plan. Each op is isolated: one failure is reported as skipped rather
102
+ * than aborting the batch, so links created earlier stay recorded in state and
103
+ * remain removable by `unlink`.
104
+ */
105
+ export function apply(paths, plan, options = {}) {
106
+ return plan.ops.map((op) => {
107
+ try {
108
+ return createLink(paths, op, options);
109
+ }
110
+ catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return { op, state: "skipped", detail: `could not link: ${message}` };
113
+ }
114
+ });
115
+ }
116
+ function createLink(paths, op, options) {
117
+ const existing = inspect(op.target);
118
+ if (existing.kind === "error") {
119
+ return { op, state: "skipped", detail: `cannot inspect: ${existing.detail}` };
120
+ }
121
+ if (existing.kind === "missing") {
122
+ if (!options.dryRun) {
123
+ mkdirSync(path.dirname(op.target), { recursive: true });
124
+ symlinkSync(relativeTo(path.dirname(op.target), op.source), op.target);
125
+ }
126
+ return { op, state: "linked" };
127
+ }
128
+ if (existing.kind === "symlink") {
129
+ if (existing.resolved === op.source)
130
+ return { op, state: "unchanged" };
131
+ if (isOwned(paths, existing.resolved)) {
132
+ if (!options.dryRun) {
133
+ unlinkSync(op.target);
134
+ symlinkSync(relativeTo(path.dirname(op.target), op.source), op.target);
135
+ }
136
+ return { op, state: "relinked" };
137
+ }
138
+ return { op, state: "skipped", detail: `already a symlink to ${existing.resolved}` };
139
+ }
140
+ return { op, state: "skipped", detail: blockerMessage(op, existing.kind) };
141
+ }
142
+ function blockerMessage(op, kind) {
143
+ if (op.kind === "instructions") {
144
+ return kind === "dir"
145
+ ? `a directory sits at ${op.rel} — remove it, or point it at AGENTS.md yourself`
146
+ : `a real instructions file sits here — merge it into AGENTS.md and re-run`;
147
+ }
148
+ return kind === "dir"
149
+ ? "a real directory sits here — run `agentlink fix` to fold it into .agents/skills"
150
+ : "a real file sits here — remove it and re-run";
151
+ }
152
+ /**
153
+ * True when agentlink created a path: the canonical AGENTS.md it links aliases
154
+ * to, or anything inside <root>/.agents. Only these are safe to replace/delete.
155
+ */
156
+ function isOwned(paths, target) {
157
+ if (!target)
158
+ return false;
159
+ if (target === paths.instructions)
160
+ return true;
161
+ const owned = `${paths.agentsDir}${path.sep}`;
162
+ return target === paths.agentsDir || target.startsWith(owned);
163
+ }
164
+ /** True when `target` is inside `root` after normalisation. */
165
+ export function isInside(root, target) {
166
+ const resolved = path.resolve(root, target);
167
+ return resolved === root || resolved.startsWith(`${root}${path.sep}`);
168
+ }
169
+ export function inspect(target) {
170
+ let stat;
171
+ try {
172
+ stat = lstatSync(target);
173
+ }
174
+ catch (error) {
175
+ const code = error?.code;
176
+ // Anything other than "does not exist" (ENOTDIR, EACCES, ELOOP, …) is a
177
+ // real problem the caller must report rather than paper over by writing.
178
+ if (code === "ENOENT")
179
+ return { kind: "missing" };
180
+ return { kind: "error", detail: `${code ?? "unknown error"}` };
181
+ }
182
+ if (stat.isSymbolicLink()) {
183
+ return { kind: "symlink", resolved: path.resolve(path.dirname(target), readlinkSync(target)) };
184
+ }
185
+ return { kind: stat.isDirectory() ? "dir" : "file" };
186
+ }
187
+ export function readState(paths) {
188
+ try {
189
+ const parsed = JSON.parse(readFileSync(paths.stateFile, "utf8"));
190
+ if (parsed?.version === 1) {
191
+ return {
192
+ version: 1,
193
+ scope: parsed.scope ?? paths.scope,
194
+ harnesses: parsed.harnesses ?? [],
195
+ ignore: parsed.ignore ?? "skills",
196
+ // A hand-edited or hostile state file must not let a later unlink reach
197
+ // outside the scope root.
198
+ links: (parsed.links ?? []).filter((link) => typeof link?.path === "string" && isInside(paths.root, link.path)),
199
+ };
200
+ }
201
+ }
202
+ catch {
203
+ /* first run */
204
+ }
205
+ return { version: 1, scope: paths.scope, harnesses: [], ignore: "skills", links: [] };
206
+ }
207
+ export function writeState(paths, state) {
208
+ mkdirSync(paths.agentsDir, { recursive: true });
209
+ writeFileAtomic(paths.stateFile, `${JSON.stringify(state, null, 2)}\n`);
210
+ }
211
+ /** Write via a temp file and rename, so an interrupted run cannot truncate. */
212
+ export function writeFileAtomic(file, contents) {
213
+ const temporary = `${file}.agentlink-tmp`;
214
+ writeFileSync(temporary, contents, "utf8");
215
+ renameSync(temporary, file);
216
+ }
217
+ /**
218
+ * Remove links agentlink owns that the current plan no longer wants: a
219
+ * deselected harness, or a skill that was renamed or deleted. Without this the
220
+ * old symlinks stay behind and dangle.
221
+ */
222
+ export function pruneStale(paths, plan, previous, options = {}) {
223
+ const wanted = new Set(plan.ops.map((op) => op.rel));
224
+ const removed = [];
225
+ for (const link of previous.links) {
226
+ if (wanted.has(link.path) || !isInside(paths.root, link.path))
227
+ continue;
228
+ const target = path.join(paths.root, link.path);
229
+ const existing = inspect(target);
230
+ if (existing.kind !== "symlink" || !isOwned(paths, existing.resolved))
231
+ continue;
232
+ if (!options.dryRun) {
233
+ unlinkSync(target);
234
+ pruneEmptyParents(path.dirname(target), paths.root);
235
+ }
236
+ removed.push(link.path);
237
+ }
238
+ return removed;
239
+ }
240
+ /** Fold this run's results into the previous state. */
241
+ export function mergeState(paths, previous, results, harnessIds, options = {}) {
242
+ const planned = options.plannedRels ? new Set(options.plannedRels) : undefined;
243
+ const links = new Map(previous.links.filter((link) => !planned || planned.has(link.path)).map((link) => [link.path, link]));
244
+ for (const result of results) {
245
+ if (result.state === "skipped")
246
+ continue;
247
+ links.set(result.op.rel, {
248
+ path: result.op.rel,
249
+ source: relativeTo(paths.root, result.op.source),
250
+ });
251
+ }
252
+ return {
253
+ version: 1,
254
+ scope: paths.scope,
255
+ harnesses: harnessIds,
256
+ ignore: options.ignore ?? previous.ignore ?? "skills",
257
+ links: [...links.values()].sort((a, b) => a.path.localeCompare(b.path)),
258
+ };
259
+ }
260
+ /**
261
+ * Remove the symlinks agentlink created. Anything that is no longer a symlink
262
+ * pointing into .agents is left alone and reported instead.
263
+ */
264
+ export function unlink(paths, options = {}) {
265
+ const state = readState(paths);
266
+ const removed = [];
267
+ const kept = [];
268
+ for (const link of state.links) {
269
+ if (!isInside(paths.root, link.path)) {
270
+ kept.push({ path: link.path, reason: "outside this scope" });
271
+ continue;
272
+ }
273
+ const target = path.join(paths.root, link.path);
274
+ const existing = inspect(target);
275
+ if (existing.kind === "missing")
276
+ continue;
277
+ if (existing.kind !== "symlink") {
278
+ kept.push({ path: link.path, reason: `no longer a symlink (real ${existing.kind})` });
279
+ continue;
280
+ }
281
+ if (!isOwned(paths, existing.resolved)) {
282
+ kept.push({ path: link.path, reason: "points outside .agents now" });
283
+ continue;
284
+ }
285
+ if (!options.dryRun) {
286
+ unlinkSync(target);
287
+ pruneEmptyParents(path.dirname(target), paths.root);
288
+ }
289
+ removed.push(link.path);
290
+ }
291
+ if (!options.dryRun)
292
+ writeState(paths, { ...state, links: [] });
293
+ return { removed, kept };
294
+ }
295
+ export function pruneEmptyParents(dir, root) {
296
+ let current = dir;
297
+ while (current !== root && current.startsWith(`${root}${path.sep}`)) {
298
+ try {
299
+ rmdirSync(current);
300
+ }
301
+ catch {
302
+ return;
303
+ }
304
+ current = path.dirname(current);
305
+ }
306
+ }
307
+ export { isOwned };