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