Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/dotagents/src/cli/commands/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ describe("runSync", () => {
expect(config.skills).toHaveLength(2);
});

it("adopts only directories that are valid skills", async () => {
await writeFile(join(projectRoot, "agents.toml"), "version = 1\n");
const skillsDir = join(projectRoot, ".agents", "skills");
// Directories another tool keeps under a migrated .claude/skills/
await mkdir(join(skillsDir, ".trash", "old"), { recursive: true });
await writeFile(join(skillsDir, ".trash", "old", "SKILL.md"), SKILL_MD("old"));
await mkdir(join(skillsDir, "synced", "bucket", "pdf"), { recursive: true });
await writeFile(join(skillsDir, "synced", "bucket", "pdf", "SKILL.md"), SKILL_MD("pdf"));
await mkdir(join(skillsDir, "notes"), { recursive: true });
await writeFile(join(skillsDir, "notes", "README.md"), "notes\n");
await mkdir(join(skillsDir, "review"), { recursive: true });
await writeFile(join(skillsDir, "review", "SKILL.md"), SKILL_MD("review"));

const result = await runSync({ scope: resolveScope("project", projectRoot) });

expect(result.adopted).toEqual(["review"]);
const config = await loadConfig(join(projectRoot, "agents.toml"));
expect(config.skills.map((s) => s.name)).toEqual(["review"]);
});

it("forgets an adopted skill whose directory no longer has a SKILL.md", async () => {
await writeFile(join(projectRoot, "agents.toml"), "version = 1\n");
await mkdir(join(projectRoot, ".agents", "skills", "notes"), { recursive: true });
await writeFile(join(projectRoot, ".agents", "skills", "notes", "README.md"), "notes\n");
await writeLockfile(join(projectRoot, "agents.lock"), {
version: 1,
skills: { notes: { source: "path:.agents/skills/notes" } },
});

const result = await runSync({ scope: resolveScope("project", projectRoot) });

expect(result.adopted).toEqual([]);
expect((await loadLockfile(join(projectRoot, "agents.lock")))!.skills).toEqual({});
const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8");
expect(gitignore).not.toContain("/skills/notes");
});

it("ignores plugin skill ownership markers when adopting orphaned skills", async () => {
await writeFile(join(projectRoot, "agents.toml"), "version = 1\n");
const markerDir = join(projectRoot, ".agents", "skills", ".dotagents-managed");
Expand Down
17 changes: 15 additions & 2 deletions packages/dotagents/src/cli/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,10 @@ export async function runSync(opts: SyncOptions): Promise<SyncResult> {
// 1. Adopt orphaned skills (installed but not in agents.toml)
if (existsSync(skillsDir)) {
const adoptedLockEntries: Record<string, { source: string }> = {};
let forgotten = false;
const entries = await readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {continue;}
if (entry.name === ".dotagents-managed") {continue;}
if (declaredNames.has(entry.name)) {continue;}

const locked = lockfile?.skills[entry.name];
Expand All @@ -111,6 +111,19 @@ export async function runSync(opts: SyncOptions): Promise<SyncResult> {
continue;
}

// Only adopt skill directories. Ownership markers, hidden directories, and
// folders without a SKILL.md (e.g. Claude Code's synced/ and .trash/ after
// .claude/skills/ is migrated) are not skills.
const skillPath = managedSkillPath(skillsDir, entry.name);
if (!skillPath || !existsSync(join(skillPath, "SKILL.md"))) {
// Drop a leftover in-place lock entry so .agents/.gitignore does not hide the folder.
if (locked) {
delete lockfile!.skills[entry.name];
forgotten = true;
}
continue;
}

const sourcePrefix = scope.scope === "user" ? "path:skills/" : "path:.agents/skills/";
const source = `${sourcePrefix}${entry.name}`;
await addSkillToConfig(configPath, entry.name, { source });
Expand All @@ -120,7 +133,7 @@ export async function runSync(opts: SyncOptions): Promise<SyncResult> {
adopted.push(entry.name);
}

if (adopted.length > 0 || pruned.length > 0) {
if (adopted.length > 0 || pruned.length > 0 || forgotten) {
lockfile = {
version: 1,
skills: { ...lockfile?.skills, ...adoptedLockEntries },
Expand Down
2 changes: 1 addition & 1 deletion specs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ dotagents sync
```

**Behavior:**
1. Adopt orphaned local skills (installed but not in `agents.toml`, and not previously managed) into config
1. Adopt orphaned local skills (directories with a valid skill name and a `SKILL.md`, installed but not in `agents.toml`, and not previously managed) into config
2. Prune stale managed skills that were removed from config but still exist on disk locally
3. In project scope, regenerate `.agents/.gitignore`
4. In project scope, warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore`
Expand Down
Loading