Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,20 +272,25 @@ receive the skills as files. Add `--json` for a machine-readable report, and
| Codex | its own plugin CLI (`--project` copies files instead) | managed by the plugin CLI | yes | start a new session |
| Cursor | copied files | `~/.cursor/skills`, or `.agents/skills` with `--project` | Cursor 3.14.27, cursor-agent 2026.08.04-aaa8809 | a new session (a new chat) |
| Windsurf (ships as Devin) | copied files | `~/.codeium/windsurf/skills`, or `.windsurf/skills` with `--project` | Devin 3.6.27, devin CLI 3000.3.27 | a new session (the next `devin` run) |
| Gemini CLI · GitHub Copilot | copied files | each vendor's documented directory | not yet | — |
| Antigravity | copied files | `~/.gemini/config/skills`, or `.agents/skills` with `--project` | Antigravity 2.0.1 | nothing — it re-reads its skills every turn, including in a conversation already open |
| GitHub Copilot | copied files | `~/.copilot/skills`, or `.agents/skills` with `--project` | not yet | — |

The project directories overlap in a way the rows above do not show: Windsurf
also reads `.agents/skills`, which is where `--project` puts things for Cursor
and Codex. A project-scope install aimed at one of them therefore makes those
skills visible in the others, and removing them for one changes what the others
see. Use user scope when you want a host's skills to itself.
also reads `.agents/skills`, which is where `--project` puts things for Cursor,
Codex and Antigravity. A project-scope install aimed at one of them therefore
makes those skills visible in the others, and removing them for one changes what
the others see. Use user scope when you want a host's skills to itself.

"Not yet" means the skills directory for that assistant comes from its
documentation and has not been confirmed by a verification run of our own: the
install works, but we cannot promise the assistant reads from where we put the
files. The remaining two are marked `(paths not yet verified)`
in the report and carry `"verified": false` in `--json`. Cursor and Windsurf
were verified with reply-cli 0.5.1.
files. GitHub Copilot is marked `(paths not yet verified)` in the report and
carries `"verified": false` in `--json`. Cursor and Windsurf were verified with
reply-cli 0.5.1, Antigravity with the release that adds it.

Antigravity is the one host that needs nothing to pick up new skills — it
re-reads them every turn, so an install lands in a conversation you already have
open.

Installing skills is not the same as connecting Reply: `reply-adapter` needs a
Reply.io login (`reply auth login`) to actually do anything.
Expand Down
164 changes: 156 additions & 8 deletions src/__tests__/skills/adapter-flat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {clone_repo, copy_dir, run_flat, skills_target} from '../../skills/adapter-flat';
import {canonical, clone_repo, copy_dir, run_flat, skills_target} from '../../skills/adapter-flat';
import {host_by_id} from '../../skills/hosts';
import {PACKS_FALLBACK, resolve_packs} from '../../skills/packs';
import {journal_entry, record_pack} from '../../skills/journal';
Expand All @@ -28,8 +28,8 @@ const cursor = (): Detected_host=>
// A second flat host that shares its project-scope directory with cursor
// (both resolve `.agents/skills`), used to exercise the multi-host sharing
// path (see 'run_flat shared project directories across hosts' below).
const gemini = (): Detected_host=>
({def: host_by_id('gemini-cli'), config_dir: path.join(home, '.gemini')});
const copilot = (): Detected_host=>
({def: host_by_id('github-copilot'), config_dir: path.join(home, '.copilot')});

// A native host reachable by run_flat only under --project (it has no
// user_skills_dir), used to exercise the "no directory for this scope" path.
Expand Down Expand Up @@ -468,22 +468,22 @@ describe('run_flat project-scope entries belong to one repository', ()=>{
// sibling's journal entry.
describe('run_flat shared project directories across hosts', ()=>{
it('does not delete a sibling host\'s install of the same pack from a shared project directory', async()=>{
fs.mkdirSync(path.join(home, '.gemini'), {recursive: true});
fs.mkdirSync(path.join(home, '.copilot'), {recursive: true});
await run_flat({...flat_opts('install', core_only), scope: 'project'});
await run_flat({...flat_opts('install', core_only), scope: 'project', host: gemini()});
await run_flat({...flat_opts('install', core_only), scope: 'project', host: copilot()});

const shared = path.join(root, 'project', '.agents', 'skills', 'ai-sdr-core-skill', 'SKILL.md');
expect(fs.existsSync(shared)).toBe(true);

const outcome = await run_flat({...flat_opts('remove', core_only), scope: 'project'});
expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'removed', version: '0.1.0'}]);
expect(journal_entry('cursor', 'project', 'ai-sdr-core', env())).toBeUndefined();
// gemini-cli's install still claims the shared file, so it survives.
// github-copilot's install still claims the shared file, so it survives.
expect(fs.existsSync(shared)).toBe(true);
expect(journal_entry('gemini-cli', 'project', 'ai-sdr-core', env())?.version).toBe('0.1.0');
expect(journal_entry('github-copilot', 'project', 'ai-sdr-core', env())?.version).toBe('0.1.0');

const list_outcome = await run_flat({
...flat_opts('list', core_only), scope: 'project', host: gemini(),
...flat_opts('list', core_only), scope: 'project', host: copilot(),
clone: async()=>{ throw new Error('list must not clone'); },
});
expect(list_outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]);
Expand All @@ -501,6 +501,51 @@ describe('run_flat with no skills directory for the scope', ()=>{
});
});

// The journal records what we wrote; it is not evidence the files are still
// there. A directory deleted by hand, moved by a host upgrade, or eaten by a
// sync tool leaves the entry untouched, and every command used to believe it:
// `install` reported `current` and copied nothing, exit 0, with `list` agreeing,
// so the one command a user reaches for to fix a broken install was the one that
// could never fix it.
describe('run_flat when the journal outlives the files', ()=>{
it('reports the pack as needing repair, and repairs it, rather than calling it current', async()=>{
await run_flat(flat_opts('install', core_only));
const target = path.join(home, '.cursor', 'skills');
fs.rmSync(target, {recursive: true, force: true});

const listed = await run_flat(flat_opts('list', core_only));
expect(listed.packs?.[0].action).toBe('failed');
expect(listed.packs?.[0].detail).toContain('run `reply skills install` to repair');

// And the command that hint names actually fixes it — `installed`, not
// `current`: at an unchanged version the files were still missing.
const outcome = await run_flat(flat_opts('install', core_only));
expect(outcome.packs?.map(p=>p.action)).toEqual(['installed']);
expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true);
});

// The deadlock. A host's skills directory moving between releases leaves
// entries pointing outside the new target root; `remove` refuses to delete
// outside it, which is right, but must not keep an entry for files that are
// not there — `install` reads that entry as `current`, and the only way out
// was editing the journal by hand.
it('forgets an entry whose recorded files are outside the target root and already gone', async()=>{
const moved_away = path.join(root, 'old-location', 'ai-sdr-core-skill', 'SKILL.md');
record_pack('cursor', 'user', 'ai-sdr-core', {
version: '0У.1.0', ref: 'main', commit: 'deadbee', scope: 'user',
files: [moved_away], complete: true, installed_at: '2026-07-30T00:00:00.000Z',
}, env());

const removed = await run_flat(flat_opts('remove', core_only));

expect(removed.packs?.map(p=>p.action)).toEqual(['removed']);
expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined();
// And the pack installs again with no hand-editing.
const outcome = await run_flat(flat_opts('install', core_only));
expect(outcome.packs?.map(p=>p.action)).toEqual(['installed']);
});
});

// Minor, requested alongside the fixes above: clone_repo must not leave its
// temp directory behind on either failure path, and must check the exit code
// of `git rev-parse HEAD` rather than journaling an empty commit.
Expand Down Expand Up @@ -738,4 +783,107 @@ describe('run_flat differently-cased journaled paths', ()=>{
expect(fs.readFileSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'), 'utf8'))
.toBe('old content');
});

// The two above are each skipped on half the platforms, so on any single
// runner neither covers the canonicalisation itself: replacing
// `fs.realpathSync.native` with `path.resolve` keeps the whole suite green.
// Injecting the canonicaliser — the seam detect.ts already uses for `exists`
// — puts the filesystem's opinion under the test's control, so what gets
// asserted is the logic: ownership recognised through a case difference,
// everywhere, and a foreign file still refused.
// Stands in for a case-insensitive filesystem by resolving each segment
// against what is on disk, case-blind. Lower-casing the whole path instead
// names a file that exists nowhere — `SKILL.MD` becomes `skill.md`, not the
// `SKILL.md` that is there — which passed on macOS only because the raw
// existence check answered first, and failed on Linux.
const ignoring_case = (target_path: string): string=>{
const [root, ...segments] = target_path.split(path.sep);
let resolved = root || path.sep;
for (const segment of segments.filter(Boolean))
{
const listed = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()
? fs.readdirSync(resolved).find(entry=>entry.toLowerCase() === segment.toLowerCase())
: undefined;
resolved = path.join(resolved, listed ?? segment);
}
return resolved;
};

it('recognises our own file through a case difference on any platform', async()=>{
const target = seed_differently_cased_entry();
// The stub answers with the spelling on disk, so the assertion below is
// about the adapter rather than about the runner's filesystem.
expect(ignoring_case(path.join(target, 'AI-SDR-CORE-SKILL', 'SKILL.MD')))
.toBe(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'));

const outcome = await run_flat({...flat_opts('update', core_only), canonicalise: ignoring_case});

expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]);
expect(fs.readFileSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'), 'utf8'))
.not.toBe('old content');
});

// Pins the branch only a case-sensitive runner reached, on every platform:
// the recorded path does not exist as written anywhere, and the canonicaliser
// is the only thing that maps it to the file that does. Without it, `install`
// and `list` disagree with owns_dir about whether the pack is present, and a
// re-copy reports `installed` on Linux and `current` on macOS for the same
// inputs — which is exactly how CI caught this.
it('counts a recorded file as present when only its canonical form exists', async()=>{
const target = path.join(home, '.cursor', 'skills');
const real = path.join(target, 'ai-sdr-core-skill', 'SKILL.md');
fs.mkdirSync(path.dirname(real), {recursive: true});
fs.writeFileSync(real, 'old content');
const recorded = path.join(target, 'recorded-under-another-name', 'SKILL.md');
record_pack('cursor', 'user', 'ai-sdr-core', {
version: '0.1.0', ref: 'main', commit: 'deadbee', scope: 'user',
files: [recorded], complete: true, installed_at: '2026-07-30T00:00:00.000Z',
}, env());

const outcome = await run_flat({
...flat_opts('install', core_only),
canonicalise: (target_path)=>target_path === recorded ? real : target_path,
});

// Complete, at the target version, and its file is there — so nothing to
// do. Judging presence on the raw path alone would call this a repair.
expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]);
expect(fs.readFileSync(real, 'utf8')).toBe('old content');
});

it('still refuses a file no journal entry claims, whatever the canonical form', async()=>{
const target = path.join(home, '.cursor', 'skills');
fs.mkdirSync(path.join(target, 'ai-sdr-core-skill'), {recursive: true});
fs.writeFileSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'), 'someone else wrote this');

const outcome = await run_flat({...flat_opts('install', core_only), canonicalise: ignoring_case});

expect(outcome.packs).toEqual([{
name: 'ai-sdr-core', action: 'failed', detail: 'conflicts with an existing skill: ai-sdr-core-skill',
}]);
expect(fs.readFileSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'), 'utf8'))
.toBe('someone else wrote this');
});
});

// The injected-canonicaliser tests above cover how owns_dir uses canonicalisation,
// but not the default's own behaviour: swapping `fs.realpathSync.native` for
// `path.resolve` leaves them all green, and only a case-insensitive runner
// notices. A symlink separates the two portably — realpath follows it, resolve
// hands the link back — so the implementation is pinned on every platform.
describe('canonical', ()=>{
it('answers with the path the filesystem really uses, following a symlink', ()=>{
Comment thread
ArtemKosolap marked this conversation as resolved.
Outdated
const real = path.join(root, 'real-skills');
fs.mkdirSync(real, {recursive: true});
const link = path.join(root, 'linked-skills');
fs.symlinkSync(real, link);

expect(canonical(link)).toBe(fs.realpathSync.native(real));
expect(canonical(link)).not.toBe(link);
});

it('falls back to a resolved path when the target does not exist', ()=>{
const missing = path.join(root, 'nowhere', 'SKILL.md');
expect(canonical(missing)).toBe(path.resolve(missing));
});
});
18 changes: 16 additions & 2 deletions src/__tests__/skills/hosts.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'path';
import {describe, it, expect} from 'vitest';
import {HOSTS, host_by_id, host_ids} from '../../skills/hosts';
import {UsageError} from '../../utils/errors';
Expand All @@ -9,12 +10,13 @@ describe('host registry', ()=>{

it('covers the hosts v1 promises', ()=>{
expect(host_ids()).toEqual(expect.arrayContaining([
'claude-code', 'codex', 'cursor', 'gemini-cli', 'github-copilot', 'windsurf',
'claude-code', 'codex', 'cursor', 'github-copilot', 'windsurf',
]));
});

it('marks only the hosts we actually verified', ()=>{
expect(HOSTS.filter(h=>h.verified).map(h=>h.id)).toEqual(['claude-code', 'codex', 'cursor', 'windsurf']);
expect(HOSTS.filter(h=>h.verified).map(h=>h.id))
.toEqual(['claude-code', 'codex', 'antigravity', 'cursor', 'windsurf']);
});

it('gives every native host a CLI and every flat host a skills directory', ()=>{
Expand All @@ -34,6 +36,18 @@ describe('host registry', ()=>{
expect(host.project_skills_dir, host.id).toBeDefined();
}
});

it('never lets one host\'s config directory contain another\'s', ()=>{
const dirs = HOSTS.flatMap(h=>h.config_dirs.map(dir=>({id: h.id, dir})));
for (const a of dirs)
{
for (const b of dirs.filter(other=>other.id !== a.id))
{
expect(a.dir === b.dir || a.dir.startsWith(`${b.dir}${path.sep}`), `${a.id} inside ${b.id}`)
.toBe(false);
}
}
});

it('builds Claude Code argument vectors', ()=>{
const cli = host_by_id('claude-code').cli!;
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/skills/journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ describe('skills journal', ()=>{

it('keeps hosts isolated', ()=>{
record_pack('cursor', 'user', 'ai-sdr-core', entry(), env());
record_pack('gemini-cli', 'user', 'ai-sdr-core', entry('0.9.0'), env());
record_pack('github-copilot', 'user', 'ai-sdr-core', entry('0.9.0'), env());
expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.1.0');
expect(journal_entry('gemini-cli', 'user', 'ai-sdr-core', env())?.version).toBe('0.9.0');
expect(journal_entry('github-copilot', 'user', 'ai-sdr-core', env())?.version).toBe('0.9.0');
});

it('keeps scopes isolated on the same host', ()=>{
Expand Down
Loading
Loading