diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 9fde6ab..71db92b 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -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"); diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 5319e40..5a5bce9 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -95,10 +95,10 @@ export async function runSync(opts: SyncOptions): Promise { // 1. Adopt orphaned skills (installed but not in agents.toml) if (existsSync(skillsDir)) { const adoptedLockEntries: Record = {}; + 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]; @@ -111,6 +111,19 @@ export async function runSync(opts: SyncOptions): Promise { 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 }); @@ -120,7 +133,7 @@ export async function runSync(opts: SyncOptions): Promise { 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 }, diff --git a/specs/SPEC.md b/specs/SPEC.md index 51c24af..3f50579 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -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`