Skip to content

Commit daa3a13

Browse files
grichacodex
andauthored
ref(cli): Centralize skill symlink targets (#128)
Move repeated scope, agent, and legacy target enumeration into a shared pure projection. Keep creation, repair, reporting, and output policy in their commands while preserving ordering, exact-string deduplication, and native skill readers. Verify sync repairs the projected legacy path and cover project and user scope semantics. Co-authored-by: OpenAI Codex <noreply@openai.com>
1 parent 3fcd123 commit daa3a13

8 files changed

Lines changed: 136 additions & 120 deletions

File tree

packages/dotagents/src/cli/commands/doctor.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ describe("runDoctor", () => {
102102
expect(check?.message).toContain("pdf");
103103
});
104104

105+
it("detects a missing agent skill symlink", async () => {
106+
await writeFile(
107+
join(projectRoot, "agents.toml"),
108+
`version = 1\nagents = ["claude", "cursor", "codex"]\n`,
109+
);
110+
await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n");
111+
await writeFile(join(projectRoot, ".agents", ".gitignore"), "# managed\n");
112+
113+
const result = await runDoctor({ scope: resolveScope("project", projectRoot) });
114+
const check = result.checks.find((c) => c.name === "symlinks");
115+
expect(check?.status).toBe("warn");
116+
expect(check?.message).toContain("1 symlink(s)");
117+
});
118+
105119
it("detects generated files tracked by git", async () => {
106120
// Initialize a git repo so git ls-files works
107121
const { execSync } = await import("node:child_process");

packages/dotagents/src/cli/commands/doctor.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { isWildcardDep } from "../../config/schema.js";
1010
import { loadLockfile } from "../../lockfile/loader.js";
1111
import { writeLockfile } from "../../lockfile/writer.js";
1212
import { verifySymlinks } from "../../symlinks/manager.js";
13-
import { getAgent } from "../../targets/registry.js";
13+
import { skillSymlinkTargets } from "../../targets/skill-symlinks.js";
1414
import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js";
1515
import { exec } from "@sentry/dotagents-lib";
1616
import { isInPlaceSkill } from "../../utils/fs.js";
@@ -194,19 +194,11 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorResult> {
194194

195195
// 9. Symlinks (project scope only)
196196
if (scope.scope === "project" && existsSync(scope.agentsDir)) {
197-
const targets: string[] = [];
198-
const seenDirs = new Set<string>();
199-
200-
for (const target of config.symlinks?.targets ?? []) {
201-
seenDirs.add(target);
202-
targets.push(`${scope.root}/${target}`);
203-
}
204-
for (const agentId of config.agents) {
205-
const agent = getAgent(agentId);
206-
if (!agent?.skillsParentDir || seenDirs.has(agent.skillsParentDir)) {continue;}
207-
seenDirs.add(agent.skillsParentDir);
208-
targets.push(`${scope.root}/${agent.skillsParentDir}`);
209-
}
197+
const targets = skillSymlinkTargets(
198+
scope,
199+
config.agents,
200+
config.symlinks?.targets,
201+
);
210202

211203
if (targets.length > 0) {
212204
const issues = await verifySymlinks(scope.agentsDir, targets);

packages/dotagents/src/cli/commands/init.ts

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { existsSync } from "node:fs";
22
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3-
import { join, resolve } from "node:path";
3+
import { join, relative, resolve } from "node:path";
44
import chalk from "chalk";
55
import { generateDefaultConfig } from "../../config/writer.js";
66
import { writeAgentsGitignore, ensureRootGitignoreEntries } from "../../gitignore/writer.js";
77
import { ensureSkillsSymlink } from "../../symlinks/manager.js";
88
import { loadConfig } from "../../config/loader.js";
9-
import { getAgent, allAgentIds, allAgents } from "../../targets/registry.js";
9+
import { allAgentIds, allAgents } from "../../targets/registry.js";
10+
import { skillSymlinkTargets } from "../../targets/skill-symlinks.js";
1011
import { parseArgs } from "node:util";
1112
import * as clack from "@clack/prompts";
1213
import { resolveScope, isInsideGitRepo, findGitDir, type ScopeRoot } from "../../scope.js";
@@ -71,36 +72,17 @@ export async function runInit(opts: InitOptions): Promise<void> {
7172
// Symlinks — create per-agent symlinks so each agent discovers skills
7273
const symlinkResults: { target: string; created: boolean; migrated: string[] }[] = [];
7374

74-
if (scope.scope === "user") {
75-
const seen = new Set<string>();
76-
for (const agentId of config.agents) {
77-
const agent = getAgent(agentId);
78-
if (!agent?.userSkillsParentDirs) {continue;}
79-
for (const dir of agent.userSkillsParentDirs) {
80-
if (seen.has(dir)) {continue;}
81-
seen.add(dir);
82-
const result = await ensureSkillsSymlink(agentsDir, dir);
83-
symlinkResults.push({ target: dir, ...result });
84-
}
85-
}
86-
} else {
87-
const targets = config.symlinks?.targets ?? [];
88-
for (const target of targets) {
89-
const targetDir = join(scope.root, target);
90-
const result = await ensureSkillsSymlink(agentsDir, targetDir);
91-
symlinkResults.push({ target, ...result });
92-
}
93-
94-
const seenParentDirs = new Set(targets);
95-
for (const agentId of config.agents) {
96-
const agent = getAgent(agentId);
97-
if (!agent?.skillsParentDir) {continue;}
98-
if (seenParentDirs.has(agent.skillsParentDir)) {continue;}
99-
seenParentDirs.add(agent.skillsParentDir);
100-
const targetDir = join(scope.root, agent.skillsParentDir);
101-
const result = await ensureSkillsSymlink(agentsDir, targetDir);
102-
symlinkResults.push({ target: agent.skillsParentDir, ...result });
103-
}
75+
const symlinkTargets = skillSymlinkTargets(
76+
scope,
77+
config.agents,
78+
config.symlinks?.targets,
79+
);
80+
for (const target of symlinkTargets) {
81+
const result = await ensureSkillsSymlink(agentsDir, target);
82+
const displayTarget = scope.scope === "project"
83+
? relative(scope.root, target)
84+
: target;
85+
symlinkResults.push({ target: displayTarget, ...result });
10486
}
10587

10688
// Auto-install declared skills (best-effort — may fail offline)

packages/dotagents/src/cli/commands/install/agent-runtime.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { join } from "node:path";
21
import type { AgentsConfig } from "../../../config/schema.js";
32
import type { ScopeRoot } from "../../../scope.js";
4-
import { getAgent } from "../../../targets/registry.js";
53
import { ensureSkillsSymlink } from "../../../symlinks/manager.js";
4+
import { skillSymlinkTargets } from "../../../targets/skill-symlinks.js";
65
import { projectMcpResolver, toMcpDeclarations, writeMcpConfigs } from "../../../targets/mcp-writer.js";
76
import { projectHookResolver, toHookDeclarations, writeHookConfigs } from "../../../targets/hook-writer.js";
87
import { userMcpResolver } from "../../../targets/paths.js";
@@ -19,32 +18,13 @@ export async function writeSkillSymlinks(
1918
config: AgentsConfig,
2019
scope: ScopeRoot,
2120
): Promise<void> {
22-
if (scope.scope === "user") {
23-
const seen = new Set<string>();
24-
for (const agentId of config.agents) {
25-
const agent = getAgent(agentId);
26-
if (!agent?.userSkillsParentDirs) {continue;}
27-
for (const dir of agent.userSkillsParentDirs) {
28-
if (seen.has(dir)) {continue;}
29-
seen.add(dir);
30-
await ensureSkillsSymlink(scope.agentsDir, dir);
31-
}
32-
}
33-
return;
34-
}
35-
36-
const targets = config.symlinks?.targets ?? [];
21+
const targets = skillSymlinkTargets(
22+
scope,
23+
config.agents,
24+
config.symlinks?.targets,
25+
);
3726
for (const target of targets) {
38-
await ensureSkillsSymlink(scope.agentsDir, join(scope.root, target));
39-
}
40-
41-
const seenParentDirs = new Set(targets);
42-
for (const agentId of config.agents) {
43-
const agent = getAgent(agentId);
44-
if (!agent?.skillsParentDir) {continue;}
45-
if (seenParentDirs.has(agent.skillsParentDir)) {continue;}
46-
seenParentDirs.add(agent.skillsParentDir);
47-
await ensureSkillsSymlink(scope.agentsDir, join(scope.root, agent.skillsParentDir));
27+
await ensureSkillsSymlink(scope.agentsDir, target);
4828
}
4929
}
5030

packages/dotagents/src/cli/commands/sync.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2-
import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises";
2+
import { lstat, mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises";
33
import { existsSync } from "node:fs";
44
import { join } from "node:path";
55
import { tmpdir } from "node:os";
@@ -344,6 +344,7 @@ describe("runSync", () => {
344344

345345
const result = await runSync({ scope: resolveScope("project", projectRoot) });
346346
expect(result.symlinksRepaired).toBe(1);
347+
expect((await lstat(join(projectRoot, ".claude", "skills"))).isSymbolicLink()).toBe(true);
347348
});
348349

349350
it("regenerates gitignore", async () => {
@@ -386,6 +387,7 @@ describe("runSync", () => {
386387

387388
const result = await runSync({ scope: resolveScope("project", projectRoot) });
388389
expect(result.symlinksRepaired).toBe(1);
390+
expect((await lstat(join(projectRoot, ".claude", "skills"))).isSymbolicLink()).toBe(true);
389391
});
390392

391393
it("repairs missing hook configs", async () => {

packages/dotagents/src/cli/commands/sync.ts

Lines changed: 10 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { writeLockfile } from "../../lockfile/writer.js";
1010
import { addSkillToConfig } from "../../config/writer.js";
1111
import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js";
1212
import { ensureSkillsSymlink, verifySymlinks } from "../../symlinks/manager.js";
13-
import { getAgent } from "../../targets/registry.js";
13+
import { skillSymlinkTargets } from "../../targets/skill-symlinks.js";
1414
import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../targets/mcp-writer.js";
1515
import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js";
1616
import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js";
@@ -164,51 +164,15 @@ export async function runSync(opts: SyncOptions): Promise<SyncResult> {
164164

165165
// 4. Verify and repair symlinks
166166
let symlinksRepaired = 0;
167-
168-
if (scope.scope === "user") {
169-
const seen = new Set<string>();
170-
const targets: string[] = [];
171-
for (const agentId of config.agents) {
172-
const agent = getAgent(agentId);
173-
if (!agent?.userSkillsParentDirs) {continue;}
174-
for (const dir of agent.userSkillsParentDirs) {
175-
if (seen.has(dir)) {continue;}
176-
seen.add(dir);
177-
targets.push(dir);
178-
}
179-
}
180-
181-
const symlinkIssues = await verifySymlinks(agentsDir, targets);
182-
for (const issue of symlinkIssues) {
183-
await ensureSkillsSymlink(agentsDir, issue.target);
184-
symlinksRepaired++;
185-
}
186-
} else {
187-
const legacyTargets = config.symlinks?.targets ?? [];
188-
const legacyIssues = await verifySymlinks(
189-
agentsDir,
190-
legacyTargets.map((t) => join(scope.root, t)),
191-
);
192-
for (const issue of legacyIssues) {
193-
await ensureSkillsSymlink(agentsDir, join(scope.root, issue.target));
194-
symlinksRepaired++;
195-
}
196-
197-
const seenParentDirs = new Set(legacyTargets);
198-
const agentTargets: string[] = [];
199-
for (const agentId of config.agents) {
200-
const agent = getAgent(agentId);
201-
if (!agent?.skillsParentDir) {continue;}
202-
if (seenParentDirs.has(agent.skillsParentDir)) {continue;}
203-
seenParentDirs.add(agent.skillsParentDir);
204-
agentTargets.push(join(scope.root, agent.skillsParentDir));
205-
}
206-
207-
const agentSymlinkIssues = await verifySymlinks(agentsDir, agentTargets);
208-
for (const issue of agentSymlinkIssues) {
209-
await ensureSkillsSymlink(agentsDir, issue.target);
210-
symlinksRepaired++;
211-
}
167+
const symlinkTargets = skillSymlinkTargets(
168+
scope,
169+
config.agents,
170+
config.symlinks?.targets,
171+
);
172+
const symlinkIssues = await verifySymlinks(agentsDir, symlinkTargets);
173+
for (const issue of symlinkIssues) {
174+
await ensureSkillsSymlink(agentsDir, issue.target);
175+
symlinksRepaired++;
212176
}
213177

214178
// 5. Verify and repair MCP configs
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { homedir } from "node:os";
2+
import { join, resolve } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
import { resolveScope } from "../scope.js";
5+
import { skillSymlinkTargets } from "./skill-symlinks.js";
6+
7+
describe("skillSymlinkTargets", () => {
8+
it("returns legacy targets before deduplicated agent targets", () => {
9+
const scope = resolveScope("project", "/workspace/project");
10+
11+
expect(
12+
skillSymlinkTargets(
13+
scope,
14+
["claude", "cursor", "codex"],
15+
[".legacy", ".claude", ".legacy"],
16+
),
17+
).toEqual([
18+
join(scope.root, ".legacy"),
19+
join(scope.root, ".claude"),
20+
]);
21+
});
22+
23+
it("returns deduplicated user targets and skips native readers", () => {
24+
const scope = resolveScope("user");
25+
26+
expect(
27+
skillSymlinkTargets(
28+
scope,
29+
["claude", "cursor", "codex", "vscode", "opencode"],
30+
[".legacy"],
31+
),
32+
).toEqual([join(homedir(), ".claude")]);
33+
});
34+
35+
it("returns absolute project targets for a relative scope root", () => {
36+
const scope = resolveScope("project", "relative/project");
37+
38+
expect(
39+
skillSymlinkTargets(scope, ["claude"], ["/legacy"]),
40+
).toEqual([
41+
resolve(join("relative/project", "/legacy")),
42+
resolve(join("relative/project", ".claude")),
43+
]);
44+
});
45+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { join, resolve } from "node:path";
2+
import type { ScopeRoot } from "../scope.js";
3+
import { getAgent } from "./registry.js";
4+
5+
/** Owns the shared projection of configured agents and legacy entries to absolute symlink targets. */
6+
export function skillSymlinkTargets(
7+
scope: ScopeRoot,
8+
agentIds: readonly string[],
9+
legacyTargets: readonly string[] = [],
10+
): string[] {
11+
const seen = new Set<string>();
12+
const targets: string[] = [];
13+
14+
if (scope.scope === "project") {
15+
for (const target of legacyTargets) {
16+
if (seen.has(target)) {continue;}
17+
seen.add(target);
18+
targets.push(target);
19+
}
20+
for (const agentId of agentIds) {
21+
const target = getAgent(agentId)?.skillsParentDir;
22+
if (!target || seen.has(target)) {continue;}
23+
seen.add(target);
24+
targets.push(target);
25+
}
26+
return targets.map((target) => resolve(join(scope.root, target)));
27+
}
28+
29+
for (const agentId of agentIds) {
30+
for (const target of getAgent(agentId)?.userSkillsParentDirs ?? []) {
31+
if (seen.has(target)) {continue;}
32+
seen.add(target);
33+
targets.push(target);
34+
}
35+
}
36+
return targets;
37+
}

0 commit comments

Comments
 (0)