@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
package/src/ignore.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "./link.js";
|
|
4
|
+
import type { ScopePaths } from "./scope.js";
|
|
5
|
+
|
|
6
|
+
export type IgnoreMode = "skills" | "all" | "none";
|
|
7
|
+
|
|
8
|
+
/** Matched as whole lines: a user comment like "# agentlink:beginning" is not our marker. */
|
|
9
|
+
const BEGIN_LINE = /^# agentlink:begin\s*$/m;
|
|
10
|
+
const END_LINE = /^# agentlink:end\s*$/m;
|
|
11
|
+
const BLOCK = "# agentlink:begin";
|
|
12
|
+
|
|
13
|
+
export const IGNORE_MODES: IgnoreMode[] = ["skills", "all", "none"];
|
|
14
|
+
|
|
15
|
+
export function isIgnoreMode(value: string): value is IgnoreMode {
|
|
16
|
+
return (IGNORE_MODES as string[]).includes(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Which created links belong in .gitignore.
|
|
21
|
+
*
|
|
22
|
+
* Instructions aliases are one small file per harness and are the thing that
|
|
23
|
+
* makes a fresh clone work for a teammate on a different harness, so they are
|
|
24
|
+
* committed. Skill links are derived, multiply with every skill and harness,
|
|
25
|
+
* and their parent directories also hold machine-local harness state, so they
|
|
26
|
+
* are ignored and regenerated by `agentlink sync`.
|
|
27
|
+
*/
|
|
28
|
+
export function ignoreEntries(
|
|
29
|
+
mode: IgnoreMode,
|
|
30
|
+
input: { skillDirs: string[]; instructionFiles: string[] },
|
|
31
|
+
): string[] {
|
|
32
|
+
const entries: string[] = [];
|
|
33
|
+
if (mode === "none") return entries;
|
|
34
|
+
for (const dir of input.skillDirs) entries.push(`${dir}/`);
|
|
35
|
+
if (mode === "all") entries.push(...input.instructionFiles);
|
|
36
|
+
return [...new Set(entries)].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type BlockStatus = "updated" | "unchanged" | "malformed";
|
|
40
|
+
|
|
41
|
+
export interface IgnoreResult {
|
|
42
|
+
status: BlockStatus;
|
|
43
|
+
file: string;
|
|
44
|
+
entries: string[];
|
|
45
|
+
skipped?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function updateGitignore(
|
|
49
|
+
paths: ScopePaths,
|
|
50
|
+
mode: IgnoreMode,
|
|
51
|
+
input: { skillDirs: string[]; instructionFiles: string[] },
|
|
52
|
+
options: { dryRun?: boolean } = {},
|
|
53
|
+
): IgnoreResult {
|
|
54
|
+
const file = path.join(paths.root, ".gitignore");
|
|
55
|
+
const entries = ignoreEntries(mode, input);
|
|
56
|
+
|
|
57
|
+
// Only touch a repository that actually tracks files here.
|
|
58
|
+
if (paths.scope !== "project" || !existsSync(path.join(paths.root, ".git"))) {
|
|
59
|
+
return { status: "unchanged", file, entries, skipped: "not a git repository" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const before = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
63
|
+
const spliced = applyIgnoreBlock(before, entries);
|
|
64
|
+
if (spliced.status === "malformed") return { status: spliced.status, file, entries };
|
|
65
|
+
|
|
66
|
+
const changed = spliced.status === "updated";
|
|
67
|
+
if (changed && !options.dryRun) writeFileAtomic(file, spliced.text);
|
|
68
|
+
return { status: spliced.status, file, entries };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove the managed block, leaving the rest of .gitignore alone. */
|
|
72
|
+
export function removeIgnoreBlock(paths: ScopePaths, options: { dryRun?: boolean } = {}): boolean {
|
|
73
|
+
const file = path.join(paths.root, ".gitignore");
|
|
74
|
+
if (!existsSync(file)) return false;
|
|
75
|
+
const before = readFileSync(file, "utf8");
|
|
76
|
+
const spliced = applyIgnoreBlock(before, []);
|
|
77
|
+
if (spliced.status !== "updated" || spliced.text === before) return false;
|
|
78
|
+
if (!options.dryRun) writeFileAtomic(file, spliced.text);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read the entries currently inside the managed block. */
|
|
83
|
+
export function readIgnoreBlock(paths: ScopePaths): string[] {
|
|
84
|
+
const text = existsSync(path.join(paths.root, ".gitignore"))
|
|
85
|
+
? readFileSync(path.join(paths.root, ".gitignore"), "utf8")
|
|
86
|
+
: "";
|
|
87
|
+
return blockSpan(text)?.entries ?? [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Span {
|
|
91
|
+
start: number;
|
|
92
|
+
end: number;
|
|
93
|
+
entries: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Locate the managed block by whole-line markers. Returns null when absent. */
|
|
97
|
+
function blockSpan(text: string): Span | null {
|
|
98
|
+
const begin = BEGIN_LINE.exec(text);
|
|
99
|
+
if (!begin) return null;
|
|
100
|
+
const afterBegin = begin.index + begin[0].length;
|
|
101
|
+
const end = END_LINE.exec(text.slice(afterBegin));
|
|
102
|
+
if (!end) return null;
|
|
103
|
+
const endIndex = afterBegin + end.index;
|
|
104
|
+
const body = text.slice(afterBegin, endIndex);
|
|
105
|
+
return {
|
|
106
|
+
start: begin.index,
|
|
107
|
+
end: endIndex + end[0].length,
|
|
108
|
+
entries: body
|
|
109
|
+
.split("\n")
|
|
110
|
+
.map((line) => line.trim())
|
|
111
|
+
.filter((line) => line.length > 0 && !line.startsWith("#")),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SpliceResult {
|
|
116
|
+
text: string;
|
|
117
|
+
status: BlockStatus;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Insert, replace or remove the managed block.
|
|
122
|
+
*
|
|
123
|
+
* Text outside the block is never rewritten: no whitespace normalisation runs
|
|
124
|
+
* over the user's file. A lone marker is refused rather than guessed at, since
|
|
125
|
+
* anything after an unterminated block might be the user's own rules.
|
|
126
|
+
*/
|
|
127
|
+
export function applyIgnoreBlock(text: string, entries: string[]): SpliceResult {
|
|
128
|
+
const beginMatch = BEGIN_LINE.exec(text);
|
|
129
|
+
const endMatch = END_LINE.exec(text);
|
|
130
|
+
if (beginMatch && !endMatch) {
|
|
131
|
+
return { text, status: "malformed" };
|
|
132
|
+
}
|
|
133
|
+
if (!beginMatch && endMatch) {
|
|
134
|
+
return { text, status: "malformed" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const span = beginMatch && endMatch ? blockSpan(text) : null;
|
|
138
|
+
const without = span ? `${text.slice(0, span.start)}${text.slice(span.end)}` : text;
|
|
139
|
+
|
|
140
|
+
if (entries.length === 0) {
|
|
141
|
+
if (!span) return { text, status: "unchanged" };
|
|
142
|
+
// Remove the blank line the block was separated by, but nothing else.
|
|
143
|
+
const trimmed = without.replace(/\n[ \t]*\n$/, "\n").replace(/\n$/, text.endsWith("\n") ? "\n" : "");
|
|
144
|
+
return { text: trimmed, status: "updated" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const block = [BLOCK, ...entries, "# agentlink:end"].join("\n");
|
|
148
|
+
if (span) {
|
|
149
|
+
const next = `${without.slice(0, span.start)}${block}\n${without.slice(span.start).replace(/^\n/, "")}`;
|
|
150
|
+
return { text: next, status: next === text ? "unchanged" : "updated" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const base = text.replace(/\s+$/, "");
|
|
154
|
+
const next = base === "" ? `${block}\n` : `${base}\n\n${block}\n`;
|
|
155
|
+
return { text: next, status: next === text ? "unchanged" : "updated" };
|
|
156
|
+
}
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readlinkSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import type { Harness } from "./harnesses.js";
|
|
16
|
+
import { listSubdirectories, relativeTo, type ScopePaths } from "./scope.js";
|
|
17
|
+
|
|
18
|
+
export type LinkKind = "instructions" | "skill";
|
|
19
|
+
|
|
20
|
+
export interface LinkOp {
|
|
21
|
+
/** Absolute path of the symlink to create. */
|
|
22
|
+
target: string;
|
|
23
|
+
/** Absolute path of the canonical source it points at. */
|
|
24
|
+
source: string;
|
|
25
|
+
/** `target` relative to the scope root, for state and display. */
|
|
26
|
+
rel: string;
|
|
27
|
+
kind: LinkKind;
|
|
28
|
+
skill?: string;
|
|
29
|
+
harnessIds: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SkipOp {
|
|
33
|
+
target: string;
|
|
34
|
+
rel: string;
|
|
35
|
+
kind: LinkKind;
|
|
36
|
+
harnessIds: string[];
|
|
37
|
+
reason: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Plan {
|
|
41
|
+
ops: LinkOp[];
|
|
42
|
+
skips: SkipOp[];
|
|
43
|
+
/** Harnesses that already read the canonical path; nothing to create. */
|
|
44
|
+
native: { harnessId: string; kind: LinkKind }[];
|
|
45
|
+
/** Harnesses whose path for this scope has no documented answer. */
|
|
46
|
+
unknown: { harnessId: string; kind: LinkKind }[];
|
|
47
|
+
/**
|
|
48
|
+
* Harness directories that are themselves symlinks to the canonical tree.
|
|
49
|
+
* Already correct, so no links are planned, but still derived paths that
|
|
50
|
+
* belong in .gitignore.
|
|
51
|
+
*/
|
|
52
|
+
aliases: { rel: string; harnessId: string }[];
|
|
53
|
+
skillsFound: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when `dir` resolves to the same place as the canonical skills tree. */
|
|
57
|
+
function resolvesToCanonicalSkills(paths: ScopePaths, dir: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(dir) === realpathSync(paths.skills);
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build the full set of links for a scope without touching the filesystem. */
|
|
66
|
+
export function plan(paths: ScopePaths, harnesses: Harness[]): Plan {
|
|
67
|
+
const ops = new Map<string, LinkOp>();
|
|
68
|
+
const skips: SkipOp[] = [];
|
|
69
|
+
const native: Plan["native"] = [];
|
|
70
|
+
const unknown: Plan["unknown"] = [];
|
|
71
|
+
const aliases: Plan["aliases"] = [];
|
|
72
|
+
|
|
73
|
+
const add = (op: Omit<LinkOp, "harnessIds">, harnessId: string) => {
|
|
74
|
+
const existing = ops.get(op.target);
|
|
75
|
+
if (existing) {
|
|
76
|
+
if (!existing.harnessIds.includes(harnessId)) existing.harnessIds.push(harnessId);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
ops.set(op.target, { ...op, harnessIds: [harnessId] });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const skip = (op: Omit<SkipOp, "harnessIds">, harnessId: string) => {
|
|
83
|
+
const existing = skips.find((s) => s.target === op.target && s.reason === op.reason);
|
|
84
|
+
if (existing) {
|
|
85
|
+
if (!existing.harnessIds.includes(harnessId)) existing.harnessIds.push(harnessId);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
skips.push({ ...op, harnessIds: [harnessId] });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const instructionsExist = existsSync(paths.instructions);
|
|
92
|
+
const skills = listSubdirectories(paths.skills);
|
|
93
|
+
|
|
94
|
+
for (const harness of harnesses) {
|
|
95
|
+
const instr = harness.instructions[paths.scope];
|
|
96
|
+
const alias = instr.native ? undefined : instr.alias;
|
|
97
|
+
if (instr.native) {
|
|
98
|
+
native.push({ harnessId: harness.id, kind: "instructions" });
|
|
99
|
+
} else if (!alias) {
|
|
100
|
+
unknown.push({ harnessId: harness.id, kind: "instructions" });
|
|
101
|
+
} else if (!instructionsExist) {
|
|
102
|
+
skip(
|
|
103
|
+
{
|
|
104
|
+
target: path.join(paths.root, alias),
|
|
105
|
+
rel: alias,
|
|
106
|
+
kind: "instructions",
|
|
107
|
+
reason: "AGENTS.md does not exist yet — run `agentlink init` first",
|
|
108
|
+
},
|
|
109
|
+
harness.id,
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
add(
|
|
113
|
+
{
|
|
114
|
+
target: path.join(paths.root, alias),
|
|
115
|
+
source: paths.instructions,
|
|
116
|
+
rel: alias,
|
|
117
|
+
kind: "instructions",
|
|
118
|
+
},
|
|
119
|
+
harness.id,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sk = harness.skills[paths.scope];
|
|
124
|
+
const skillsAlias = sk.native ? undefined : sk.alias;
|
|
125
|
+
if (sk.native) {
|
|
126
|
+
native.push({ harnessId: harness.id, kind: "skill" });
|
|
127
|
+
} else if (!skillsAlias) {
|
|
128
|
+
unknown.push({ harnessId: harness.id, kind: "skill" });
|
|
129
|
+
} else if (resolvesToCanonicalSkills(paths, path.join(paths.root, skillsAlias))) {
|
|
130
|
+
// The harness directory is a symlink to .agents/skills, which is already
|
|
131
|
+
// the arrangement this tool exists to create.
|
|
132
|
+
aliases.push({ rel: skillsAlias, harnessId: harness.id });
|
|
133
|
+
} else if (skills.length === 0) {
|
|
134
|
+
skip(
|
|
135
|
+
{
|
|
136
|
+
target: path.join(paths.root, skillsAlias),
|
|
137
|
+
rel: skillsAlias,
|
|
138
|
+
kind: "skill",
|
|
139
|
+
reason: "no skills in .agents/skills yet",
|
|
140
|
+
},
|
|
141
|
+
harness.id,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
for (const skill of skills) {
|
|
145
|
+
add(
|
|
146
|
+
{
|
|
147
|
+
target: path.join(paths.root, skillsAlias, skill),
|
|
148
|
+
source: path.join(paths.skills, skill),
|
|
149
|
+
rel: path.posix.join(skillsAlias, skill),
|
|
150
|
+
kind: "skill",
|
|
151
|
+
skill,
|
|
152
|
+
},
|
|
153
|
+
harness.id,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { ops: [...ops.values()], skips, native, unknown, aliases, skillsFound: skills };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type LinkState = "linked" | "relinked" | "unchanged" | "skipped";
|
|
163
|
+
|
|
164
|
+
export interface ApplyResult {
|
|
165
|
+
op: LinkOp;
|
|
166
|
+
state: LinkState;
|
|
167
|
+
detail?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Apply a plan. Each op is isolated: one failure is reported as skipped rather
|
|
172
|
+
* than aborting the batch, so links created earlier stay recorded in state and
|
|
173
|
+
* remain removable by `unlink`.
|
|
174
|
+
*/
|
|
175
|
+
export function apply(paths: ScopePaths, plan: Plan, options: { dryRun?: boolean } = {}): ApplyResult[] {
|
|
176
|
+
return plan.ops.map((op) => {
|
|
177
|
+
try {
|
|
178
|
+
return createLink(paths, op, options);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
return { op, state: "skipped" as const, detail: `could not link: ${message}` };
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function createLink(paths: ScopePaths, op: LinkOp, options: { dryRun?: boolean }): ApplyResult {
|
|
187
|
+
const existing = inspect(op.target);
|
|
188
|
+
|
|
189
|
+
if (existing.kind === "error") {
|
|
190
|
+
return { op, state: "skipped", detail: `cannot inspect: ${existing.detail}` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (existing.kind === "missing") {
|
|
194
|
+
if (!options.dryRun) {
|
|
195
|
+
mkdirSync(path.dirname(op.target), { recursive: true });
|
|
196
|
+
symlinkSync(relativeTo(path.dirname(op.target), op.source), op.target);
|
|
197
|
+
}
|
|
198
|
+
return { op, state: "linked" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (existing.kind === "symlink") {
|
|
202
|
+
if (existing.resolved === op.source) return { op, state: "unchanged" };
|
|
203
|
+
if (isOwned(paths, existing.resolved)) {
|
|
204
|
+
if (!options.dryRun) {
|
|
205
|
+
unlinkSync(op.target);
|
|
206
|
+
symlinkSync(relativeTo(path.dirname(op.target), op.source), op.target);
|
|
207
|
+
}
|
|
208
|
+
return { op, state: "relinked" };
|
|
209
|
+
}
|
|
210
|
+
return { op, state: "skipped", detail: `already a symlink to ${existing.resolved}` };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { op, state: "skipped", detail: blockerMessage(op, existing.kind) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function blockerMessage(op: LinkOp, kind: "file" | "dir"): string {
|
|
217
|
+
if (op.kind === "instructions") {
|
|
218
|
+
return kind === "dir"
|
|
219
|
+
? `a directory sits at ${op.rel} — remove it, or point it at AGENTS.md yourself`
|
|
220
|
+
: `a real instructions file sits here — merge it into AGENTS.md and re-run`;
|
|
221
|
+
}
|
|
222
|
+
return kind === "dir"
|
|
223
|
+
? "a real directory sits here — run `agentlink fix` to fold it into .agents/skills"
|
|
224
|
+
: "a real file sits here — remove it and re-run";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* True when agentlink created a path: the canonical AGENTS.md it links aliases
|
|
229
|
+
* to, or anything inside <root>/.agents. Only these are safe to replace/delete.
|
|
230
|
+
*/
|
|
231
|
+
function isOwned(paths: ScopePaths, target: string | undefined): boolean {
|
|
232
|
+
if (!target) return false;
|
|
233
|
+
if (target === paths.instructions) return true;
|
|
234
|
+
const owned = `${paths.agentsDir}${path.sep}`;
|
|
235
|
+
return target === paths.agentsDir || target.startsWith(owned);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** True when `target` is inside `root` after normalisation. */
|
|
239
|
+
export function isInside(root: string, target: string): boolean {
|
|
240
|
+
const resolved = path.resolve(root, target);
|
|
241
|
+
return resolved === root || resolved.startsWith(`${root}${path.sep}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface Inspection {
|
|
245
|
+
kind: "missing" | "symlink" | "file" | "dir" | "error";
|
|
246
|
+
resolved?: string;
|
|
247
|
+
detail?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function inspect(target: string): Inspection {
|
|
251
|
+
let stat;
|
|
252
|
+
try {
|
|
253
|
+
stat = lstatSync(target);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
256
|
+
// Anything other than "does not exist" (ENOTDIR, EACCES, ELOOP, …) is a
|
|
257
|
+
// real problem the caller must report rather than paper over by writing.
|
|
258
|
+
if (code === "ENOENT") return { kind: "missing" };
|
|
259
|
+
return { kind: "error", detail: `${code ?? "unknown error"}` };
|
|
260
|
+
}
|
|
261
|
+
if (stat.isSymbolicLink()) {
|
|
262
|
+
return { kind: "symlink", resolved: path.resolve(path.dirname(target), readlinkSync(target)) };
|
|
263
|
+
}
|
|
264
|
+
return { kind: stat.isDirectory() ? "dir" : "file" };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- state ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export interface State {
|
|
270
|
+
version: 1;
|
|
271
|
+
scope: string;
|
|
272
|
+
harnesses: string[];
|
|
273
|
+
/** Which created links are listed in .gitignore. */
|
|
274
|
+
ignore: string;
|
|
275
|
+
links: { path: string; source: string }[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function readState(paths: ScopePaths): State {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(readFileSync(paths.stateFile, "utf8")) as Partial<State>;
|
|
281
|
+
if (parsed?.version === 1) {
|
|
282
|
+
return {
|
|
283
|
+
version: 1,
|
|
284
|
+
scope: parsed.scope ?? paths.scope,
|
|
285
|
+
harnesses: parsed.harnesses ?? [],
|
|
286
|
+
ignore: parsed.ignore ?? "skills",
|
|
287
|
+
// A hand-edited or hostile state file must not let a later unlink reach
|
|
288
|
+
// outside the scope root.
|
|
289
|
+
links: (parsed.links ?? []).filter(
|
|
290
|
+
(link) => typeof link?.path === "string" && isInside(paths.root, link.path),
|
|
291
|
+
),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
/* first run */
|
|
296
|
+
}
|
|
297
|
+
return { version: 1, scope: paths.scope, harnesses: [], ignore: "skills", links: [] };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function writeState(paths: ScopePaths, state: State): void {
|
|
301
|
+
mkdirSync(paths.agentsDir, { recursive: true });
|
|
302
|
+
writeFileAtomic(paths.stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Write via a temp file and rename, so an interrupted run cannot truncate. */
|
|
306
|
+
export function writeFileAtomic(file: string, contents: string): void {
|
|
307
|
+
const temporary = `${file}.agentlink-tmp`;
|
|
308
|
+
writeFileSync(temporary, contents, "utf8");
|
|
309
|
+
renameSync(temporary, file);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Remove links agentlink owns that the current plan no longer wants: a
|
|
314
|
+
* deselected harness, or a skill that was renamed or deleted. Without this the
|
|
315
|
+
* old symlinks stay behind and dangle.
|
|
316
|
+
*/
|
|
317
|
+
export function pruneStale(
|
|
318
|
+
paths: ScopePaths,
|
|
319
|
+
plan: Plan,
|
|
320
|
+
previous: State,
|
|
321
|
+
options: { dryRun?: boolean } = {},
|
|
322
|
+
): string[] {
|
|
323
|
+
const wanted = new Set(plan.ops.map((op) => op.rel));
|
|
324
|
+
const removed: string[] = [];
|
|
325
|
+
|
|
326
|
+
for (const link of previous.links) {
|
|
327
|
+
if (wanted.has(link.path) || !isInside(paths.root, link.path)) continue;
|
|
328
|
+
const target = path.join(paths.root, link.path);
|
|
329
|
+
const existing = inspect(target);
|
|
330
|
+
if (existing.kind !== "symlink" || !isOwned(paths, existing.resolved)) continue;
|
|
331
|
+
if (!options.dryRun) {
|
|
332
|
+
unlinkSync(target);
|
|
333
|
+
pruneEmptyParents(path.dirname(target), paths.root);
|
|
334
|
+
}
|
|
335
|
+
removed.push(link.path);
|
|
336
|
+
}
|
|
337
|
+
return removed;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface MergeOptions {
|
|
341
|
+
ignore?: string;
|
|
342
|
+
/** Kept links shrink to this set; pair with pruneStale to delete them. */
|
|
343
|
+
plannedRels?: string[];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Fold this run's results into the previous state. */
|
|
347
|
+
export function mergeState(
|
|
348
|
+
paths: ScopePaths,
|
|
349
|
+
previous: State,
|
|
350
|
+
results: ApplyResult[],
|
|
351
|
+
harnessIds: string[],
|
|
352
|
+
options: MergeOptions = {},
|
|
353
|
+
): State {
|
|
354
|
+
const planned = options.plannedRels ? new Set(options.plannedRels) : undefined;
|
|
355
|
+
const links = new Map(
|
|
356
|
+
previous.links.filter((link) => !planned || planned.has(link.path)).map((link) => [link.path, link]),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
for (const result of results) {
|
|
360
|
+
if (result.state === "skipped") continue;
|
|
361
|
+
links.set(result.op.rel, {
|
|
362
|
+
path: result.op.rel,
|
|
363
|
+
source: relativeTo(paths.root, result.op.source),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
version: 1,
|
|
369
|
+
scope: paths.scope,
|
|
370
|
+
harnesses: harnessIds,
|
|
371
|
+
ignore: options.ignore ?? previous.ignore ?? "skills",
|
|
372
|
+
links: [...links.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface UnlinkResult {
|
|
377
|
+
removed: string[];
|
|
378
|
+
kept: { path: string; reason: string }[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Remove the symlinks agentlink created. Anything that is no longer a symlink
|
|
383
|
+
* pointing into .agents is left alone and reported instead.
|
|
384
|
+
*/
|
|
385
|
+
export function unlink(paths: ScopePaths, options: { dryRun?: boolean } = {}): UnlinkResult {
|
|
386
|
+
const state = readState(paths);
|
|
387
|
+
const removed: string[] = [];
|
|
388
|
+
const kept: { path: string; reason: string }[] = [];
|
|
389
|
+
|
|
390
|
+
for (const link of state.links) {
|
|
391
|
+
if (!isInside(paths.root, link.path)) {
|
|
392
|
+
kept.push({ path: link.path, reason: "outside this scope" });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const target = path.join(paths.root, link.path);
|
|
396
|
+
const existing = inspect(target);
|
|
397
|
+
if (existing.kind === "missing") continue;
|
|
398
|
+
if (existing.kind !== "symlink") {
|
|
399
|
+
kept.push({ path: link.path, reason: `no longer a symlink (real ${existing.kind})` });
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (!isOwned(paths, existing.resolved)) {
|
|
403
|
+
kept.push({ path: link.path, reason: "points outside .agents now" });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (!options.dryRun) {
|
|
407
|
+
unlinkSync(target);
|
|
408
|
+
pruneEmptyParents(path.dirname(target), paths.root);
|
|
409
|
+
}
|
|
410
|
+
removed.push(link.path);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!options.dryRun) writeState(paths, { ...state, links: [] });
|
|
414
|
+
return { removed, kept };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function pruneEmptyParents(dir: string, root: string): void {
|
|
418
|
+
let current = dir;
|
|
419
|
+
while (current !== root && current.startsWith(`${root}${path.sep}`)) {
|
|
420
|
+
try {
|
|
421
|
+
rmdirSync(current);
|
|
422
|
+
} catch {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
current = path.dirname(current);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export { isOwned };
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export type Scope = "project" | "global";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything agentlink needs to know about *where* it is working.
|
|
9
|
+
*
|
|
10
|
+
* The convention has exactly two roots: a repository root (project scope) and
|
|
11
|
+
* $HOME (global scope). Both hold the same three things:
|
|
12
|
+
*
|
|
13
|
+
* <root>/AGENTS.md instructions, source of truth
|
|
14
|
+
* <root>/.agents/skills/<name> skills, source of truth
|
|
15
|
+
* <root>/.agents/agentlink.json what agentlink has linked
|
|
16
|
+
*/
|
|
17
|
+
export interface ScopePaths {
|
|
18
|
+
scope: Scope;
|
|
19
|
+
root: string;
|
|
20
|
+
agentsDir: string;
|
|
21
|
+
instructions: string;
|
|
22
|
+
skills: string;
|
|
23
|
+
stateFile: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveScope(scope: Scope, cwd: string): ScopePaths {
|
|
27
|
+
const root = scope === "global" ? homedir() : (findRepoRoot(cwd) ?? cwd);
|
|
28
|
+
const agentsDir = path.join(root, ".agents");
|
|
29
|
+
return {
|
|
30
|
+
scope,
|
|
31
|
+
root,
|
|
32
|
+
agentsDir,
|
|
33
|
+
instructions: path.join(root, "AGENTS.md"),
|
|
34
|
+
skills: path.join(agentsDir, "skills"),
|
|
35
|
+
stateFile: path.join(agentsDir, "agentlink.json"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Walk up for a `.git` entry (directory, or a file for linked worktrees).
|
|
41
|
+
* Returns null when no repository root exists above cwd.
|
|
42
|
+
*/
|
|
43
|
+
export function findRepoRoot(cwd: string): string | null {
|
|
44
|
+
let dir = path.resolve(cwd);
|
|
45
|
+
for (;;) {
|
|
46
|
+
if (existsSync(path.join(dir, ".git"))) return dir;
|
|
47
|
+
const parent = path.dirname(dir);
|
|
48
|
+
if (parent === dir) return null;
|
|
49
|
+
dir = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function expandHome(input: string): string {
|
|
54
|
+
if (input === "~") return homedir();
|
|
55
|
+
if (input.startsWith("~/")) return path.join(homedir(), input.slice(2));
|
|
56
|
+
return input;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function exists(target: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
statSync(target);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isDirectory(target: string): boolean {
|
|
69
|
+
try {
|
|
70
|
+
return statSync(target).isDirectory();
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Names of the immediate subdirectories of `dir`, sorted; `[]` when missing. */
|
|
77
|
+
export function listSubdirectories(dir: string): string[] {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const names: string[] = [];
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (entry.name.startsWith(".")) continue;
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
names.push(entry.name);
|
|
89
|
+
} else if (entry.isSymbolicLink()) {
|
|
90
|
+
// A symlink is only a skill if it resolves to a directory. A dangling or
|
|
91
|
+
// file-valued link would otherwise become a broken link in every harness.
|
|
92
|
+
try {
|
|
93
|
+
if (statSync(path.join(dir, entry.name)).isDirectory()) names.push(entry.name);
|
|
94
|
+
} catch {
|
|
95
|
+
/* broken symlink: reported by doctor, never linked */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return names.sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Names of dot-directories, which the convention reserves and skips. */
|
|
103
|
+
export function listHiddenSubdirectories(dir: string): string[] {
|
|
104
|
+
try {
|
|
105
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
106
|
+
.filter((entry) => entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()))
|
|
107
|
+
.map((entry) => entry.name)
|
|
108
|
+
.sort();
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function relativeTo(fromDir: string, target: string): string {
|
|
115
|
+
const rel = path.relative(fromDir, target);
|
|
116
|
+
return rel === "" ? "." : rel;
|
|
117
|
+
}
|