@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.
package/src/fix.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ readlinkSync,
8
+ realpathSync,
9
+ renameSync,
10
+ rmSync,
11
+ } from "node:fs";
12
+ import path from "node:path";
13
+ import type { Harness } from "./harnesses.js";
14
+ import { inspect, isOwned, isInside, pruneEmptyParents } from "./link.js";
15
+ import { listSubdirectories, relativeTo, type ScopePaths } from "./scope.js";
16
+
17
+ export type FixKind = "move" | "remove-identical" | "conflict";
18
+
19
+ export interface FixAction {
20
+ /** Path relative to the scope root, inside a harness directory. */
21
+ target: string;
22
+ /** Path relative to the scope root, in the canonical tree. */
23
+ canonical: string;
24
+ kind: FixKind;
25
+ skill?: string;
26
+ detail?: string;
27
+ }
28
+
29
+ /**
30
+ * Find real copies sitting where a symlink belongs.
31
+ *
32
+ * A copy is cheap to resolve when the canonical tree has nothing at that name
33
+ * (move it in) or has an identical directory (delete the copy). When the two
34
+ * differ, nothing is guessed: the caller reports both paths and stops, unless
35
+ * the user explicitly asks for the canonical copy to win.
36
+ */
37
+ export function planFixes(paths: ScopePaths, harnesses: Harness[]): FixAction[] {
38
+ const actions: FixAction[] = [];
39
+ const seen = new Set<string>();
40
+
41
+ for (const harness of harnesses) {
42
+ const skills = harness.skills[paths.scope];
43
+ if (!skills.native && skills.alias) {
44
+ const dir = path.join(paths.root, skills.alias);
45
+ // A harness directory that is itself a symlink into the canonical tree
46
+ // would make every entry look like an identical duplicate of itself.
47
+ // Acting on that would delete the canonical skills.
48
+ if (isInsideCanonical(paths, dir)) continue;
49
+
50
+ for (const name of listSubdirectories(dir)) {
51
+ const target = path.join(dir, name);
52
+ const rel = relativeTo(paths.root, target);
53
+ if (seen.has(rel)) continue;
54
+ seen.add(rel);
55
+
56
+ // Only real directories are copies. Symlinks are already correct, and
57
+ // a symlinked directory is resolved through, never replaced.
58
+ if (inspect(target).kind !== "dir") continue;
59
+ if (isInsideCanonical(paths, target)) continue;
60
+
61
+ const source = path.join(paths.skills, name);
62
+ const canonical = relativeTo(paths.root, source);
63
+ if (!existsSync(source)) {
64
+ actions.push({ target: rel, canonical, kind: "move", skill: name });
65
+ } else if (hashTree(target) === hashTree(source)) {
66
+ actions.push({ target: rel, canonical, kind: "remove-identical", skill: name });
67
+ } else {
68
+ actions.push({ target: rel, canonical, kind: "conflict", skill: name, detail: "both copies differ" });
69
+ }
70
+ }
71
+ }
72
+
73
+ // Instructions: a real file or directory where a symlink belongs needs a human.
74
+ const instructions = harness.instructions[paths.scope];
75
+ if (!instructions.native && instructions.alias && existsSync(paths.instructions)) {
76
+ const target = path.join(paths.root, instructions.alias);
77
+ const rel = relativeTo(paths.root, target);
78
+ const kind = inspect(target).kind;
79
+ if (!seen.has(rel) && (kind === "file" || kind === "dir")) {
80
+ seen.add(rel);
81
+ actions.push({
82
+ target: rel,
83
+ canonical: relativeTo(paths.root, paths.instructions),
84
+ kind: "conflict",
85
+ detail: "two copies of your instructions",
86
+ });
87
+ }
88
+ }
89
+ }
90
+
91
+ return actions;
92
+ }
93
+
94
+ /** True when `candidate` is the canonical skills tree, or inside it. */
95
+ function isInsideCanonical(paths: ScopePaths, candidate: string): boolean {
96
+ if (!existsSync(candidate)) return false;
97
+ let real: string;
98
+ try {
99
+ real = realpathSync(candidate);
100
+ } catch {
101
+ return false;
102
+ }
103
+ let skillsReal: string;
104
+ try {
105
+ skillsReal = realpathSync(paths.skills);
106
+ } catch {
107
+ return false;
108
+ }
109
+ if (isInside(skillsReal, real)) return true;
110
+ try {
111
+ // Resolving the parent catches a canonical entry that is itself a symlink
112
+ // into .agents, which realpath already collapsed above.
113
+ return isInside(realpathSync(paths.agentsDir), real);
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ export interface FixResult extends FixAction {
120
+ performed: boolean;
121
+ }
122
+
123
+ export function applyFixes(
124
+ paths: ScopePaths,
125
+ actions: FixAction[],
126
+ options: { dryRun?: boolean; force?: boolean } = {},
127
+ ): FixResult[] {
128
+ return actions.map((action) => {
129
+ if (action.kind === "conflict" && !options.force) return { ...action, performed: false };
130
+
131
+ const target = path.join(paths.root, action.target);
132
+ const canonical = path.join(paths.root, action.canonical);
133
+
134
+ // Never touch anything that is not inside the scope root, and never touch
135
+ // the canonical tree itself.
136
+ if (!isInside(paths.root, target) || isOwned(paths, target)) {
137
+ return { ...action, performed: false, detail: "refused: outside the scope root" };
138
+ }
139
+ if (options.dryRun) return { ...action, performed: false };
140
+
141
+ try {
142
+ if (action.kind === "move") {
143
+ mkdirSync(path.dirname(canonical), { recursive: true });
144
+ renameSync(target, canonical);
145
+ } else {
146
+ rmSync(target, { recursive: true, force: true });
147
+ }
148
+ pruneEmptyParents(path.dirname(target), paths.root);
149
+ return { ...action, performed: true };
150
+ } catch (error) {
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ return { ...action, performed: false, detail: `failed: ${message}` };
153
+ }
154
+ });
155
+ }
156
+
157
+ /** Stable hash of a directory tree, tolerant of line-ending differences. */
158
+ export function hashTree(dir: string): string {
159
+ const hash = createHash("sha256");
160
+ walk(dir, "", hash);
161
+ return hash.digest("hex");
162
+ }
163
+
164
+ function walk(current: string, prefix: string, hash: ReturnType<typeof createHash>): void {
165
+ let entries;
166
+ try {
167
+ entries = readdirSync(current, { withFileTypes: true });
168
+ } catch (error) {
169
+ // An unreadable directory must not look like an empty one.
170
+ hash.update(`unreadable ${prefix} ${(error as NodeJS.ErrnoException)?.code ?? "?"}\n`);
171
+ return;
172
+ }
173
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
174
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
175
+ const full = path.join(current, entry.name);
176
+ if (entry.isSymbolicLink()) {
177
+ hash.update(`link ${rel} ${safeReadlink(full)}\n`);
178
+ } else if (entry.isDirectory()) {
179
+ hash.update(`dir ${rel}\n`);
180
+ walk(full, rel, hash);
181
+ } else {
182
+ hash.update(`file ${rel} ${hashFile(full)}\n`);
183
+ }
184
+ }
185
+ }
186
+
187
+ function hashFile(file: string): string {
188
+ try {
189
+ const buffer = readFileSync(file);
190
+ // Treat CRLF and LF files as equal so a Windows-authored copy is not
191
+ // reported as a conflict.
192
+ const isBinary = buffer.includes(0);
193
+ const content = isBinary ? buffer : Buffer.from(buffer.toString("utf8").replace(/\r\n/g, "\n"));
194
+ return createHash("sha256").update(content).digest("hex");
195
+ } catch (error) {
196
+ // A constant sentinel would make two different unreadable files compare
197
+ // equal, which would pick "identical" and delete one of them.
198
+ const code = (error as NodeJS.ErrnoException)?.code ?? "?";
199
+ return `unreadable:${code}:${Buffer.from(file).toString("base64")}`;
200
+ }
201
+ }
202
+
203
+ function safeReadlink(file: string): string {
204
+ try {
205
+ return readlinkSync(file);
206
+ } catch {
207
+ return "?";
208
+ }
209
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * The harness table.
3
+ *
4
+ * Every row states, for one coding-agent harness, two things per scope
5
+ * (project / global):
6
+ *
7
+ * 1. the *instructions* file it loads (AGENTS.md, CLAUDE.md, …), and
8
+ * 2. the *skills* directory it scans.
9
+ *
10
+ * An endpoint is either `native` — the harness reads the canonical path
11
+ * (`AGENTS.md`, `.agents/skills/`) on its own, so agentlink creates nothing —
12
+ * or it has an `alias`, a path we symlink to the canonical source.
13
+ *
14
+ * An endpoint with neither `native: true` nor `alias` is *unknown*: we do not
15
+ * invent a path for it. `agentlink list` prints it as unavailable rather than
16
+ * writing a symlink somewhere the harness never looks.
17
+ *
18
+ * `source` is the documentation each row was read from, so a wrong row is a
19
+ * one-line fix rather than archaeology. `verified: false` marks rows whose
20
+ * documentation could not be confirmed.
21
+ */
22
+ import type { Scope } from "./scope.js";
23
+
24
+ export interface Endpoint {
25
+ /** The harness reads AGENTS.md / .agents/skills directly. */
26
+ native: boolean;
27
+ /** Path relative to the scope root; symlinked to the canonical source. */
28
+ alias?: string;
29
+ note?: string;
30
+ /** False when no vendor documentation confirms this path. Defaults to true. */
31
+ verified?: boolean;
32
+ }
33
+
34
+ export interface Harness {
35
+ id: string;
36
+ label: string;
37
+ /** Executables checked on PATH for detection. */
38
+ bins: string[];
39
+ /** Config directory relative to $HOME (from `herdr integration status`). */
40
+ configRoot: string;
41
+ instructions: Record<Scope, Endpoint>;
42
+ skills: Record<Scope, Endpoint>;
43
+ /** Primary documentation these paths were read from. */
44
+ source: string;
45
+ }
46
+
47
+ export function endpointVerified(endpoint: Endpoint): boolean {
48
+ return endpoint.verified !== false;
49
+ }
50
+
51
+ /** Endpoints in a scope that no vendor documentation confirms. */
52
+ export function unverifiedEndpoints(
53
+ harness: Harness,
54
+ scope: Scope,
55
+ ): { kind: "instructions" | "skills"; endpoint: Endpoint }[] {
56
+ const found: { kind: "instructions" | "skills"; endpoint: Endpoint }[] = [];
57
+ for (const kind of ["instructions", "skills"] as const) {
58
+ const endpoint = harness[kind][scope];
59
+ if (!endpointVerified(endpoint)) found.push({ kind, endpoint });
60
+ }
61
+ return found;
62
+ }
63
+
64
+ const PI_SKILLS_DOC =
65
+ "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md";
66
+ const CODEX_SKILLS_DOC = "https://developers.openai.com/codex/skills";
67
+ const CLAUDE_SKILLS_DOC = "https://code.claude.com/docs/en/skills";
68
+ const COPILOT_SKILLS_DOC =
69
+ "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills";
70
+ const COPILOT_INSTRUCTIONS_DOC =
71
+ "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions";
72
+
73
+ /** Build a endpoints pair: project + global in one call. */
74
+ function scopes(project: Endpoint, global: Endpoint): Record<Scope, Endpoint> {
75
+ return { project, global };
76
+ }
77
+
78
+ const NATIVE: Endpoint = { native: true };
79
+
80
+ export const HARNESSES: Harness[] = [
81
+ {
82
+ id: "claude",
83
+ label: "Claude Code",
84
+ bins: ["claude"],
85
+ configRoot: ".claude",
86
+ // Claude Code reads CLAUDE.md, not AGENTS.md, and never scans .agents/skills.
87
+ instructions: scopes(
88
+ { native: false, alias: "CLAUDE.md", note: "Claude Code has no AGENTS.md fallback" },
89
+ { native: false, alias: ".claude/CLAUDE.md" },
90
+ ),
91
+ skills: scopes(
92
+ { native: false, alias: ".claude/skills" },
93
+ { native: false, alias: ".claude/skills" },
94
+ ),
95
+ source: CLAUDE_SKILLS_DOC,
96
+ },
97
+ {
98
+ id: "codex",
99
+ label: "OpenAI Codex CLI",
100
+ bins: ["codex"],
101
+ configRoot: ".codex",
102
+ // Codex reads AGENTS.md in the repo and ~/.codex/AGENTS.md globally.
103
+ instructions: scopes(
104
+ { native: true },
105
+ { native: false, alias: ".codex/AGENTS.md" },
106
+ ),
107
+ // Codex's user-level skills directory is $HOME/.agents/skills: native both ways.
108
+ skills: scopes(NATIVE, NATIVE),
109
+ source: CODEX_SKILLS_DOC,
110
+ },
111
+ {
112
+ id: "pi",
113
+ label: "Pi",
114
+ bins: ["pi"],
115
+ configRoot: ".pi",
116
+ instructions: scopes(
117
+ { native: true },
118
+ { native: false, alias: ".pi/agent/AGENTS.md" },
119
+ ),
120
+ // Pi reads ~/.agents/skills and .agents/skills directly.
121
+ skills: scopes(NATIVE, NATIVE),
122
+ source: PI_SKILLS_DOC,
123
+ },
124
+ {
125
+ id: "omp",
126
+ label: "Oh My Pi (omp)",
127
+ bins: ["omp"],
128
+ configRoot: ".omp",
129
+ instructions: scopes(
130
+ { native: true, note: "standalone AGENTS.md via the agents-md provider" },
131
+ { native: false, alias: ".omp/agent/AGENTS.md" },
132
+ ),
133
+ // omp scans its own .omp/skills trees; it does not read .agents/skills.
134
+ skills: scopes(
135
+ { native: false, alias: ".omp/skills" },
136
+ { native: false, alias: ".omp/agent/skills" },
137
+ ),
138
+ source: "https://github.com/can1357/oh-my-pi/blob/main/docs/config-usage.md",
139
+ },
140
+ {
141
+ id: "copilot",
142
+ label: "GitHub Copilot CLI",
143
+ bins: ["copilot"],
144
+ configRoot: ".copilot",
145
+ // Copilot CLI reads AGENTS.md everywhere, but its *user* instructions live
146
+ // in a differently named file.
147
+ instructions: scopes(
148
+ { native: true },
149
+ { native: false, alias: ".copilot/copilot-instructions.md" },
150
+ ),
151
+ skills: scopes(NATIVE, NATIVE),
152
+ source: `${COPILOT_SKILLS_DOC} · ${COPILOT_INSTRUCTIONS_DOC}`,
153
+ },
154
+ {
155
+ id: "cursor",
156
+ label: "Cursor",
157
+ bins: ["cursor-agent", "cursor"],
158
+ configRoot: ".cursor",
159
+ instructions: scopes(
160
+ { native: true, note: "Cursor reads AGENTS.md" },
161
+ { native: false, alias: ".cursor/AGENTS.md", note: "not documented by Cursor", verified: false },
162
+ ),
163
+ skills: scopes(
164
+ { native: false, alias: ".cursor/skills", verified: false },
165
+ { native: false, alias: ".cursor/skills", verified: false },
166
+ ),
167
+ source: "https://cursor.com/docs/agent/context",
168
+ },
169
+ {
170
+ id: "opencode",
171
+ label: "opencode",
172
+ bins: ["opencode"],
173
+ configRoot: ".config/opencode",
174
+ instructions: scopes(
175
+ { native: true },
176
+ { native: false, alias: ".config/opencode/AGENTS.md" },
177
+ ),
178
+ skills: scopes(
179
+ { native: false, alias: ".opencode/skills", verified: false },
180
+ { native: false, alias: ".config/opencode/skills", verified: false },
181
+ ),
182
+ source: "https://opencode.ai/docs/rules/",
183
+ },
184
+ {
185
+ id: "qwen",
186
+ label: "Qwen Code",
187
+ bins: ["qwen"],
188
+ configRoot: ".qwen",
189
+ instructions: scopes(
190
+ { native: true, note: "QWEN.md is the legacy name" },
191
+ { native: false, alias: ".qwen/AGENTS.md", note: "not documented by Qwen", verified: false },
192
+ ),
193
+ skills: scopes(
194
+ { native: false, alias: ".qwen/skills" },
195
+ { native: false, alias: ".qwen/skills" },
196
+ ),
197
+ source: "https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/",
198
+ },
199
+ {
200
+ id: "kimi",
201
+ label: "Kimi Code CLI",
202
+ bins: ["kimi"],
203
+ configRoot: ".kimi-code",
204
+ instructions: scopes(
205
+ { native: true },
206
+ { native: false, alias: ".kimi-code/AGENTS.md", note: "not documented by Kimi", verified: false },
207
+ ),
208
+ skills: scopes(
209
+ { native: false, alias: ".kimi-code/skills" },
210
+ NATIVE, // Kimi scans ~/.agents/skills as its "generic group"
211
+ ),
212
+ source: "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/skills.html",
213
+ },
214
+ {
215
+ id: "kilo",
216
+ label: "Kilo Code",
217
+ bins: ["kilo"],
218
+ configRoot: ".config/kilo",
219
+ instructions: scopes(
220
+ { native: true },
221
+ { native: false, alias: ".config/kilo/AGENTS.md", note: "not documented by Kilo" },
222
+ ),
223
+ // Kilo Code reads Claude Code's skills directory for compatibility.
224
+ skills: scopes(
225
+ { native: false, alias: ".claude/skills", note: "shares Claude Code's directory", verified: false },
226
+ { native: false, alias: ".config/kilo/skills", note: "unconfirmed", verified: false },
227
+ ),
228
+ source: "https://github.com/intellectronica/ruler#skills-support-experimental",
229
+ },
230
+ {
231
+ id: "droid",
232
+ label: "Factory Droid",
233
+ bins: ["droid"],
234
+ configRoot: ".factory",
235
+ instructions: scopes(
236
+ { native: true, note: "AGENTS.md may also live in the home directory" },
237
+ { native: false, alias: ".factory/AGENTS.md" },
238
+ ),
239
+ skills: scopes(
240
+ { native: false, alias: ".factory/skills" },
241
+ { native: false, alias: ".factory/skills" },
242
+ ),
243
+ source: "https://docs.factory.ai/harness/skills",
244
+ },
245
+ {
246
+ id: "devin",
247
+ label: "Devin CLI",
248
+ bins: ["devin"],
249
+ configRoot: ".config/devin",
250
+ instructions: scopes(
251
+ { native: true },
252
+ { native: false, alias: ".config/devin/AGENTS.md" },
253
+ ),
254
+ skills: scopes(
255
+ { native: false, alias: ".devin/skills" },
256
+ { native: false, alias: ".config/devin/skills" },
257
+ ),
258
+ source: "https://docs.devin.ai/cli/extensibility/rules",
259
+ },
260
+ {
261
+ id: "mastracode",
262
+ label: "Mastra Code",
263
+ bins: ["mastracode"],
264
+ configRoot: ".mastracode",
265
+ instructions: scopes(
266
+ { native: true },
267
+ { native: false, alias: ".mastracode/AGENTS.md", note: "not documented by Mastra", verified: false },
268
+ ),
269
+ // Mastra Code lists .agents/skills as a project source and Agent Skills
270
+ // spec compatibility, so both scopes are native.
271
+ skills: scopes(NATIVE, NATIVE),
272
+ source: "https://code.mastra.ai/configuration",
273
+ },
274
+ {
275
+ id: "grok",
276
+ label: "Grok CLI",
277
+ bins: ["grok"],
278
+ configRoot: ".grok",
279
+ instructions: scopes(
280
+ { native: true, note: "reads AGENTS.md, CLAUDE.md, AGENT.md" },
281
+ { native: false, alias: ".grok/AGENTS.md", note: "not documented by Grok", verified: false },
282
+ ),
283
+ skills: scopes(
284
+ { native: false, alias: ".grok/skills" },
285
+ { native: false, alias: ".grok/skills" },
286
+ ),
287
+ source: "https://docs.x.ai/docs/grok-cli/skills",
288
+ },
289
+ {
290
+ id: "qoder",
291
+ label: "Qoder CLI",
292
+ bins: ["qodercli", "qoder"],
293
+ configRoot: ".qoder",
294
+ instructions: scopes(
295
+ { native: true, note: "configurable via context.fileName" },
296
+ { native: false, alias: ".qoder/AGENTS.md", note: "not documented by Qoder", verified: false },
297
+ ),
298
+ skills: scopes(
299
+ { native: false, alias: ".qoder/skills" },
300
+ { native: false, alias: ".qoder/skills" },
301
+ ),
302
+ source: "https://docs.qoder.com/cli/Skills",
303
+ },
304
+ {
305
+ id: "antigravity",
306
+ label: "Antigravity CLI",
307
+ bins: ["agy", "antigravity"],
308
+ configRoot: ".gemini/config",
309
+ instructions: scopes(
310
+ { native: true, note: "Gemini-lineage discovery: AGENTS.md, CONTEXT.md, GEMINI.md", verified: false },
311
+ { native: false, alias: ".gemini/config/AGENTS.md", note: "unconfirmed", verified: false },
312
+ ),
313
+ skills: scopes(
314
+ { native: false, alias: ".agent/skills", note: "unconfirmed", verified: false },
315
+ { native: false, alias: ".gemini/config/skills", note: "unconfirmed", verified: false },
316
+ ),
317
+ source: "https://github.com/intellectronica/ruler#skills-support-experimental",
318
+ },
319
+ {
320
+ id: "hermes",
321
+ label: "Hermes",
322
+ bins: ["hermes"],
323
+ configRoot: ".hermes",
324
+ instructions: scopes(
325
+ { native: true, note: "unconfirmed", verified: false },
326
+ { native: false, alias: ".hermes/AGENTS.md", note: "unconfirmed" },
327
+ ),
328
+ skills: scopes(
329
+ { native: false, alias: ".hermes/skills", note: "unconfirmed", verified: false },
330
+ { native: false, alias: ".hermes/skills", note: "unconfirmed", verified: false },
331
+ ),
332
+ source: "https://herdr.dev/llms.txt",
333
+ },
334
+ ];
335
+
336
+ export const HARNESS_IDS = HARNESSES.map((h) => h.id);
337
+
338
+ export function findHarness(id: string): Harness | undefined {
339
+ const needle = id.trim().toLowerCase();
340
+ return HARNESSES.find((h) => h.id === needle || h.label.toLowerCase() === needle);
341
+ }
342
+
343
+ /** Resolve a comma/space separated harness list; unknown ids are returned to the caller. */
344
+ export function resolveHarnessList(input: string): { found: Harness[]; unknown: string[] } {
345
+ const found: Harness[] = [];
346
+ const unknown: string[] = [];
347
+ for (const raw of input.split(/[,\s]+/).filter(Boolean)) {
348
+ const harness = findHarness(raw);
349
+ if (harness) found.push(harness);
350
+ else unknown.push(raw);
351
+ }
352
+ return { found, unknown };
353
+ }