diff --git a/.gitignore b/.gitignore index edc5d77..febe22a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.tgz coverage/ +.superpowers/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8de2f72..a0088bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,19 @@ server. `fetch` is stubbed, and the OAuth loopback flow is exercised against a local `127.0.0.1` listener with an injected browser stub. CI runs the build and tests on Linux and Windows. +`npm run smoke:hosts` is a separate check that runs `reply skills install` +entirely inside a throwaway `HOME`/`USERPROFILE` sandbox, and proves your real +home is untouched with a before/after filesystem snapshot. Native hosts +(Claude Code, Codex) are only genuinely exercised when actually installed — +each is additionally pointed at a throwaway config directory +(`CLAUDE_CONFIG_DIR`, `CODEX_HOME`) so its real plugin state is untouched +either way. Flat-directory hosts (Cursor, Gemini CLI, GitHub Copilot) are +always *simulated* inside the sandbox, regardless of what is really on your +machine — that's deliberate, since it's the only way the flat-directory +install path gets exercised at all. It is not part of `npm test` because it +clones from GitHub and, for native hosts, needs a real assistant installed to +exercise for real. + ## Conventions - Data is written to stdout; status and error messages go to stderr. diff --git a/README.md b/README.md index e3fd4a7..726ca8c 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,68 @@ It prints `{ "code": , "data": }` and exits non-zero on HTTP stderr. Add `--verbose` for a full request/response trace on stderr with credentials redacted; stdout stays the plain JSON, so pipes keep working. +## Skills + +Reply's outbound expertise ships as three markdown skill packs in +[reply-skills](https://github.com/reply-team/reply-skills). One command installs +them into every AI assistant on your machine, dependencies resolved: + +```sh +reply skills install +``` + +``` +✓ detected Claude Code, Codex +✓ Claude Code · ai-sdr-core, reply-adapter, agentic-runtime installed +✓ Codex · ai-sdr-core, reply-adapter, agentic-runtime installed +Start a new session in each assistant so the skills load. +``` + +| Pack | Alias | What it gives your agent | +|---|---|---| +| `ai-sdr-core` | `core` | Vendor-neutral SDR operations, playbooks and guardrails | +| `reply-adapter` | `adapter` | Executing those operations against Reply.io | +| `agentic-runtime` | `runtime` | Durable multi-session work: plans, checkpoints, reports | + +Install a subset — dependencies come along automatically, so `adapter` pulls +`core`: + +```sh +reply skills install core +reply skills install adapter runtime +reply skills install --agent codex # only this assistant +reply skills install --project # into this repository, not your home +``` + +Then manage them: + +```sh +reply skills list # what's installed where (notes packs with an update available) +reply skills update # bring installed packs to the latest version +reply skills remove runtime # remove one pack +reply skills remove # remove all of them +``` + +On Claude Code and Codex the packs are installed through the assistant's own +plugin mechanism, so they keep updating through it. Other `SKILL.md` hosts +receive the skills as files. Add `--json` for a machine-readable report, and +`--dry-run` to see the plan without changing anything. + +| Assistant | How it receives the packs | Paths verified | +|---|---|---| +| Claude Code | its own plugin CLI | yes | +| Codex | its own plugin CLI (`--project` copies files instead) | yes | +| Cursor · Gemini CLI · GitHub Copilot · Windsurf | copied files | not yet | + +"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 +(REPLY-51268): the install works, but we cannot promise the assistant reads +from where we put the files. Those hosts are marked `(paths not yet verified)` +in the report and carry `"verified": false` in `--json`. + +Installing skills is not the same as connecting Reply: `reply-adapter` needs a +Reply.io login (`reply auth login`) to actually do anything. + ## Environment variables | Variable | Description | diff --git a/package.json b/package.json index d537cd2..8c81044 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "clean": "rm -rf dist", "test": "vitest run", "test:watch": "vitest", + "smoke:hosts": "node scripts/smoke-hosts.mjs", "prepare": "husky" }, "author": "Reply.io", diff --git a/scripts/smoke-hosts.mjs b/scripts/smoke-hosts.mjs new file mode 100644 index 0000000..85e5052 --- /dev/null +++ b/scripts/smoke-hosts.mjs @@ -0,0 +1,326 @@ +// Verifies `reply skills install` end to end, against real and simulated assistants. +// +// Not part of `npm test`: it clones from GitHub, and exercising a native host for +// real needs that assistant installed on the machine. It is safe to run on a +// working machine because: +// - Native hosts (Claude Code, Codex) are pointed at throwaway config directories +// via CLAUDE_CONFIG_DIR and CODEX_HOME. They are only *genuinely* exercised +// (real binary resolved from the real PATH) when really installed — otherwise +// they still appear in the report with status 'skipped'. +// - Flat-directory hosts (Cursor, Gemini CLI, GitHub Copilot) are always +// *simulated* inside the sandbox below, regardless of what is really on this +// machine. That is deliberate: it is the only way the flat-directory install +// path gets exercised at all, on any machine. Do not read a flat host's 'ok' +// status here as evidence that assistant is really installed. +// - Both rely on HOME/USERPROFILE being redirected to the sandbox. That is not +// assumed: before anything mutating runs, a child process given the exact same +// env is asked what os.homedir() resolves to, and the run aborts without +// making changes if it isn't inside the sandbox (see the pre-flight checks). +// +// The script uses snapshot-based comparison to prove the real home is untouched: +// - Before any CLI invocation — before even that isolation proof — snapshot each +// flat host's real root config directory and skills leaf directory, plus the +// real reply config directory. +// - After the smoke test, verify the snapshots are identical. +// - Any filesystem change (new dir, removed entry) fails the script non-zero, +// and this comparison always runs, even if an earlier assertion already failed. +// +// This design catches isolation failures: if HOME/USERPROFILE redirection fails, +// the CLI would write to the real home, changing its snapshot. +// +// Usage: npm run build && npm run smoke:hosts + +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-smoke-')); +const env = { + ...process.env, + HOME: sandbox, + USERPROFILE: sandbox, + CLAUDE_CONFIG_DIR: path.join(sandbox, 'claude'), + CODEX_HOME: path.join(sandbox, 'codex'), + REPLY_CONFIG_DIR: path.join(sandbox, 'reply'), +}; +// Create marker directories in sandbox so detection works (otherwise would find nothing) +const config_dirs = [ + env.CLAUDE_CONFIG_DIR, + env.CODEX_HOME, + env.REPLY_CONFIG_DIR, + path.join(sandbox, '.claude'), + path.join(sandbox, '.codex'), + path.join(sandbox, '.copilot'), + path.join(sandbox, '.cursor'), + path.join(sandbox, '.gemini'), + path.join(sandbox, '.codeium'), +]; +for (const dir of config_dirs) +{ + fs.mkdirSync(dir, {recursive: true}); +} + +const cli = (...args)=>execFileSync(process.execPath, ['dist/index.js', ...args], {env, encoding: 'utf8'}); + +const fail = (message)=>{ + console.error(`✗ ${message}`); + process.exitCode = 1; +}; + +// Resolve the real reply config directory: same logic as the CLI. +// On Windows: %APPDATA%/reply; on Unix: $XDG_CONFIG_HOME/reply or ~/.config/reply +const real_reply_config_dir = ()=>{ + if (process.platform === 'win32') + { + const appdata = process.env.APPDATA; + if (appdata) + { + return path.join(appdata, 'reply'); + } + } + const xdg = process.env.XDG_CONFIG_HOME; + if (xdg) + { + return path.join(xdg, 'reply'); + } + return path.join(os.homedir(), '.config', 'reply'); +}; + +// Take a snapshot of all real flat-host directories before making any changes. +// We snapshot the filesystem state (directory listing sorted) or "does not exist" for each path. +// +// Each host's root config directory is watched alongside its skills leaf +// directory. The root is what `detect_hosts` keys presence on (see +// src/skills/detect.ts) — an empty root with no `skills` subdirectory inside +// it is still a host that the next `reply skills install` will detect and +// write into, so a leaf-only snapshot misses exactly that case. +const snapshot_state = ()=>{ + const real_home = os.homedir(); + const paths_to_check = [ + path.join(real_home, '.copilot'), + path.join(real_home, '.copilot', 'skills'), + path.join(real_home, '.cursor'), + path.join(real_home, '.cursor', 'skills'), + path.join(real_home, '.gemini'), + path.join(real_home, '.gemini', 'skills'), + path.join(real_home, '.codeium'), + path.join(real_home, '.codeium', 'windsurf', 'skills'), + path.join(real_home, '.agents'), + path.join(real_home, '.agents', 'skills'), + real_reply_config_dir(), + ]; + + const snapshot = {}; + for (const dir of paths_to_check) + { + if (!fs.existsSync(dir)) + { + snapshot[dir] = 'DOES_NOT_EXIST'; + continue; + } + try { + const contents = fs.readdirSync(dir).sort(); + snapshot[dir] = contents; + } catch (e) { + snapshot[dir] = `ERROR: ${e.message}`; + } + } + return snapshot; +}; + +// Compare two snapshots and report any differences. +const compare_snapshots = (before, after)=>{ + const diffs = []; + const all_paths = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const p of all_paths) + { + const before_state = before[p]; + const after_state = after[p]; + + if (JSON.stringify(before_state) !== JSON.stringify(after_state)) + { + if (before_state === 'DOES_NOT_EXIST' && after_state !== 'DOES_NOT_EXIST') + { + diffs.push(`CREATED: ${p}`); + } + else if (before_state !== 'DOES_NOT_EXIST' && after_state === 'DOES_NOT_EXIST') + { + diffs.push(`DELETED: ${p}`); + } + else if (typeof before_state === 'object' && typeof after_state === 'object') + { + const before_set = new Set(before_state); + const after_set = new Set(after_state); + const added = [...after_set].filter(x=>!before_set.has(x)); + const removed = [...before_set].filter(x=>!after_set.has(x)); + if (added.length > 0) + { + diffs.push(`ADDED to ${p}: ${added.join(', ')}`); + } + if (removed.length > 0) + { + diffs.push(`REMOVED from ${p}: ${removed.join(', ')}`); + } + } + else + { + diffs.push(`CHANGED ${p}: ${JSON.stringify(before_state)} → ${JSON.stringify(after_state)}`); + } + } + } + return diffs; +}; + +try { + console.log(`sandbox: ${sandbox}`); + + // Take snapshot of real environment BEFORE any CLI invocation. + // This catches even read-only operations that unexpectedly write. + const before_snapshot = snapshot_state(); + + // Everything that can fail (an assertion or an unexpected thrown error) is + // contained here so that, no matter what goes wrong, execution always + // reaches the post-run comparison below — never via process.exit(), which + // would skip both that comparison and the sandbox cleanup in `finally`. + // + // `can_proceed` gates each stage instead of nested if/else: a failure at + // any stage stops the remaining mutating stages (matching the original + // "abort without making changes" intent) without ever exiting early, so + // control still always reaches the post-run comparison after this block. + try { + // Pre-flight assertion #1: prove HOME/USERPROFILE redirection is + // actually in effect, before anything mutating runs. The empty-journal + // check below does NOT establish this — reads are gated by + // REPLY_CONFIG_DIR, which is set unconditionally regardless of whether + // HOME/USERPROFILE redirection works, so it would report "clean" even + // with a fully broken redirect. This checks the property every child + // process actually depends on directly: spawn a child with the exact + // same env this script uses for the CLI, and ask it what os.homedir() + // resolves to. + const homedir_probe = execFileSync( + process.execPath, + ['-e', 'process.stdout.write(require("os").homedir())'], + {env, encoding: 'utf8'}, + ); + const resolved_probe = path.resolve(homedir_probe); + const resolved_sandbox = path.resolve(sandbox); + let can_proceed = resolved_probe === resolved_sandbox; + if (!can_proceed) + { + fail(`isolation proof failed: a child process given this script's env resolved os.homedir() to '${resolved_probe}', not the sandbox '${resolved_sandbox}'. Aborting without making changes.`); + } + else + { + console.log(`✓ pre-flight assertion: os.homedir() in a child process resolves inside the sandbox (${resolved_probe})`); + } + + // Pre-flight assertion #2: the sandboxed journal reports no packs yet. + // This is a sanity check for stale state left by a previous interrupted + // run — not an isolation proof (see above) — since it only reads + // whatever REPLY_CONFIG_DIR points at. + if (can_proceed) + { + const sandbox_check = JSON.parse(cli('skills', 'list', '--json')); + const installed_in_sandbox = sandbox_check.hosts.flatMap(h=>(h.packs ?? []).length); + const has_plugins = installed_in_sandbox.some(count=>count > 0); + if (has_plugins) + { + fail('sandbox isolation check failed: plugins already installed. Aborting without making changes.'); + can_proceed = false; + } + else + { + console.log('✓ pre-flight assertion: sandbox journal is clean'); + } + } + + if (can_proceed) + { + const installed = JSON.parse(cli('skills', 'install', '--json')); + console.log(`hosts: ${installed.hosts.map(h=>`${h.host}=${h.status}`).join(' ') || '(none detected)'}`); + + // Flat hosts are always simulated (see header), so `installed.hosts` + // is never actually empty on any machine with git on PATH — the + // brief's original "no assistant at all" exit is dead code below. + // What is real and worth reporting is whether a *native* host (the + // only kind that is only exercised when genuinely installed) was + // actually present: status 'skipped' means its config directory + // marker existed but the real binary could not be resolved from + // the real PATH, i.e. it is not really installed here. + const native_ids = new Set(['claude-code', 'codex']); + const native_present = installed.hosts.some(h=>native_ids.has(h.host) && h.status !== 'skipped'); + console.log(native_present + ? '✓ at least one native assistant (Claude Code/Codex) is really installed and was exercised for real' + : 'ℹ no native assistant (Claude Code, Codex) is really installed on this machine — only the always-simulated flat hosts were exercised'); + + if (!installed.hosts.length) + { + console.log('⚠ no hosts detected at all — nothing to verify on this machine'); + can_proceed = false; + } + + if (can_proceed) + { + if (!installed.hosts.some(h=>h.status === 'ok')) + { + fail('no host reported ok'); + } + if (installed.resolved.join(',') !== 'ai-sdr-core,reply-adapter,agentic-runtime') + { + fail(`unexpected resolve order: ${installed.resolved.join(',')}`); + } + + // Idempotency: the second run must change nothing. + const again = JSON.parse(cli('skills', 'install', '--json')); + const actions = again.hosts.flatMap(h=>(h.packs ?? []).map(p=>p.action)); + if (actions.some(a=>a !== 'current')) + { + fail(`re-install was not idempotent: ${actions.join(',')}`); + } + + // Selective install pulls the core. + cli('skills', 'remove', '--json'); + const selective = JSON.parse(cli('skills', 'install', 'adapter', '--json')); + if (selective.resolved.join(',') !== 'ai-sdr-core,reply-adapter') + { + fail(`selective install did not pull the core: ${selective.resolved.join(',')}`); + } + + // Removing the core alone must be refused. + try { + cli('skills', 'remove', 'core', '--json'); + fail('removing the core while the adapter is installed was allowed'); + } catch { + console.log('✓ removing a needed dependency is refused'); + } + } + } + } catch (e) { + fail(`smoke run failed unexpectedly: ${e.message}`); + } + + // Post-run assertion: snapshot the real environment again and verify it is unchanged. + // This is the critical safety check. Any difference means isolation failed. + // It runs unconditionally — even if an assertion above already failed — so a + // failing smoke still tells the user whether their machine was touched. + const after_snapshot = snapshot_state(); + const diffs = compare_snapshots(before_snapshot, after_snapshot); + + if (diffs.length > 0) + { + fail(`post-run assertion failed: real home was modified:\n${diffs.map(d=>` ${d}`).join('\n')}`); + } + else + { + console.log('✓ post-run assertion: real home is untouched'); + } + + if (!process.exitCode) + { + console.log('✓ smoke passed'); + } +} finally { + fs.rmSync(sandbox, {recursive: true, force: true}); +} diff --git a/src/__tests__/commands/skills.test.ts b/src/__tests__/commands/skills.test.ts new file mode 100644 index 0000000..ad5b74b --- /dev/null +++ b/src/__tests__/commands/skills.test.ts @@ -0,0 +1,173 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const mock_run_skills = vi.hoisted(()=>vi.fn()); +vi.mock('../../skills/orchestrate', ()=>({run_skills: mock_run_skills})); + +import {handle_skills, skills_command} from '../../commands/skills'; +import type {Report} from '../../skills/types'; + +const report = (over: Partial = {}): Report=>({ + action: 'install', + source: {repo: 'reply-team/reply-skills', ref: 'main'}, + requested: ['ai-sdr-core'], + resolved: ['ai-sdr-core'], + hosts: [{ + host: 'claude-code', label: 'Claude Code', kind: 'native-plugin', scope: 'user', status: 'ok', + packs: [{name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}], + }], + summary: {installed: 1, skipped: 0, failed: 0}, + ...over, +}); + +const capture = async(fn: ()=>unknown | Promise): Promise<{out: string; err: string}>=>{ + const out: string[] = []; + const err: string[] = []; + const log = console.log; + const error = console.error; + const write = process.stdout.write; + console.log = (...a: unknown[])=>{ out.push(a.join(' ')); }; + console.error = (...a: unknown[])=>{ err.push(a.join(' ')); }; + process.stdout.write = ((c: unknown): boolean=>{ out.push(String(c)); return true; }) as typeof process.stdout.write; + try { await fn(); } finally { console.log = log; console.error = error; process.stdout.write = write; } + const clean = (s: string[]): string=>s.join('\n').replace(/\x1b\[[0-9;]*m/g, '').trim(); + return {out: clean(out), err: clean(err)}; +}; + +let dir: string; +beforeEach(()=>{ + vi.clearAllMocks(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-skills-cmd-')); + process.env.REPLY_CONFIG_DIR = dir; +}); +afterEach(()=>{ + delete process.env.REPLY_CONFIG_DIR; + fs.rmSync(dir, {recursive: true, force: true}); +}); + +describe('handle_skills', ()=>{ + // I1: the output contract splits by what the line *is*. install/update/ + // remove print progress, which is status and belongs on stderr; `list` + // prints data, which the user redirects, so it belongs on stdout. + it('prints install progress on stderr and nothing on stdout', async()=>{ + mock_run_skills.mockResolvedValue(report()); + const {out, err} = await capture(()=>handle_skills('install', [], {})); + expect(err).toContain('detected Claude Code'); + expect(err).toContain('ai-sdr-core installed'); + expect(out).toBe(''); + }); + + it('prints the list table on stdout and nothing on stderr, so `skills list > file` works', async()=>{ + mock_run_skills.mockResolvedValue(report({ + action: 'list', + hosts: [{ + host: 'claude-code', label: 'Claude Code', kind: 'native-plugin', scope: 'user', status: 'ok', + packs: [{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}], + }], + })); + const {out, err} = await capture(()=>handle_skills('list', [], {})); + expect(out).toContain('detected Claude Code'); + expect(out).toContain('ai-sdr-core already current'); + expect(err).toBe(''); + }); + + it('keeps remove progress on stderr', async()=>{ + mock_run_skills.mockResolvedValue(report({action: 'remove'})); + const {out, err} = await capture(()=>handle_skills('remove', [], {})); + expect(err).toContain('detected Claude Code'); + expect(out).toBe(''); + }); + + it('prints the report as JSON on stdout when --json is set', async()=>{ + mock_run_skills.mockResolvedValue(report()); + const {out} = await capture(()=>handle_skills('install', [], {json: true})); + const parsed = JSON.parse(out); + expect(parsed.action).toBe('install'); + expect(parsed.resolved).toEqual(['ai-sdr-core']); + expect(parsed.hosts[0].host).toBe('claude-code'); + }); + + it('indents JSON with --pretty', async()=>{ + mock_run_skills.mockResolvedValue(report()); + const {out} = await capture(()=>handle_skills('install', [], {pretty: true})); + expect(out).toContain('\n "action"'); + }); + + it('passes packs, agents, project and dry-run through to the orchestrator', async()=>{ + mock_run_skills.mockResolvedValue(report()); + await capture(()=>handle_skills('install', ['adapter'], {agent: ['codex'], project: true, dryRun: true})); + expect(mock_run_skills).toHaveBeenCalledWith(expect.objectContaining({ + operation: 'install', requested: ['adapter'], agents: ['codex'], project: true, dry_run: true, + })); + }); + + it('throws a RuntimeError so the top-level handler exits 1 when nothing installed', async()=>{ + mock_run_skills.mockResolvedValue(report({ + hosts: [], summary: {installed: 0, skipped: 0, failed: 0}, + })); + await expect(capture(()=>handle_skills('install', [], {}))).rejects.toMatchObject({ + exit_code: 1, + code: 'skills.nothing_installed', + title: 'No assistant received the skills.', + }); + }); + + // I4: the same handler serves all four operations, so the exit-1 message + // has to name the one that actually ran — a failed `remove` telling the + // user to try `install --dry-run` sends them the wrong way. + it('names the operation that actually ran in the exit-1 error', async()=>{ + for (const [operation, code, probe] of [ + ['remove', 'skills.nothing_removed', 'skills remove --dry-run'], + ['update', 'skills.nothing_updated', 'skills update --dry-run'], + ] as const) + { + mock_run_skills.mockResolvedValue(report({ + action: operation, hosts: [], summary: {installed: 0, skipped: 0, failed: 0}, + })); + const failure = await capture(()=>handle_skills(operation, [], {})) + .then(()=>undefined, (e: Error & {code?: string; hint?: string; title?: string})=>e); + expect(failure?.code).toBe(code); + expect(failure?.hint).toContain(probe); + expect(failure?.title).not.toContain('received the skills'); + } + }); + + it('still prints the report before failing, so the reason is not lost', async()=>{ + mock_run_skills.mockResolvedValue(report({ + hosts: [{ + host: 'codex', label: 'Codex', kind: 'native-plugin', status: 'failed', + reason: 'marketplace-add-failed', detail: 'no network', + }], + summary: {installed: 0, skipped: 0, failed: 1}, + })); + const {err} = await capture(()=>handle_skills('install', [], {}).catch(()=>{})); + expect(err).toContain('no network'); + }); + + it('does not fail a list run that found nothing', async()=>{ + mock_run_skills.mockResolvedValue(report({ + action: 'list', hosts: [], summary: {installed: 0, skipped: 0, failed: 0}, + })); + await expect(capture(()=>handle_skills('list', [], {}))).resolves.toBeTruthy(); + }); +}); + +describe('skills_command', ()=>{ + it('exposes the four subcommands', ()=>{ + expect(skills_command.commands.map(c=>c.name()).sort()) + .toEqual(['install', 'list', 'remove', 'update']); + }); + + it('accepts variadic pack names on install', ()=>{ + const install = skills_command.commands.find(c=>c.name() === 'install'); + expect(install?.usage()).toContain('[packs...]'); + }); + + it('declares --agent, --project and --dry-run on install', ()=>{ + const install = skills_command.commands.find(c=>c.name() === 'install'); + const flags = install?.options.map(o=>o.long) ?? []; + expect(flags).toEqual(expect.arrayContaining(['--agent', '--project', '--dry-run'])); + }); +}); diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts new file mode 100644 index 0000000..6f35177 --- /dev/null +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -0,0 +1,741 @@ +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 {host_by_id} from '../../skills/hosts'; +import {PACKS_FALLBACK, resolve_packs} from '../../skills/packs'; +import {journal_entry, record_pack} from '../../skills/journal'; +import type {Detected_host} from '../../skills/detect'; +import type {Runner} from '../../skills/types'; + +const all = resolve_packs([], PACKS_FALLBACK); +const core_only = resolve_packs(['core'], PACKS_FALLBACK); +// reply-adapter and agentic-runtime, requested directly with no dependency +// expansion, so ai-sdr-core is absent from the set — used to put two packs +// that do not depend on each other in the same run without either blocking +// the other via ai-sdr-core (see 'run_flat abort status' below). +const adapter_and_runtime_only = resolve_packs(['adapter', 'runtime'], PACKS_FALLBACK, {dependencies: false}); + +let root: string; +let home: string; +let clone_dir: string; +const env = ()=>({REPLY_CONFIG_DIR: path.join(root, 'config')}); + +const cursor = (): Detected_host=> + ({def: host_by_id('cursor'), config_dir: path.join(home, '.cursor')}); + +// 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')}); + +// 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. +const codex = (): Detected_host=> + ({def: host_by_id('codex'), config_dir: path.join(home, '.codex')}); + +// Stands in for `git clone`: builds the pack layout the real repo has. +const fake_clone = async(): Promise<{dir: string; commit: string}>=>{ + for (const pack of all) + { + const skills = path.join(clone_dir, 'plugins', pack.name, 'skills'); + fs.mkdirSync(path.join(skills, `${pack.name}-skill`), {recursive: true}); + fs.writeFileSync( + path.join(skills, `${pack.name}-skill`, 'SKILL.md'), + `---\nname: ${pack.name}-skill\ndescription: d\n---\n`, + ); + } + return {dir: clone_dir, commit: 'deadbee'}; +}; + +const flat_opts = (operation: 'install' | 'remove' | 'list' | 'update', packs = all)=>({ + operation, host: cursor(), packs, scope: 'user' as const, ref: 'main', + home, cwd: path.join(root, 'project'), env: env(), clone: fake_clone, +}); + +beforeEach(()=>{ + root = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-flat-')); + home = path.join(root, 'home'); + clone_dir = path.join(root, 'clone'); + fs.mkdirSync(path.join(home, '.cursor'), {recursive: true}); +}); +afterEach(()=>{ + fs.rmSync(root, {recursive: true, force: true}); +}); + +describe('skills_target', ()=>{ + it('uses the host user directory for user scope', ()=>{ + expect(skills_target(host_by_id('cursor'), 'user', home, '/p')).toBe(path.join(home, '.cursor', 'skills')); + }); + + it('uses the project directory for project scope', ()=>{ + expect(skills_target(host_by_id('cursor'), 'project', home, '/p')).toBe(path.join('/p', '.agents', 'skills')); + }); + + it('sends a native host under --project to its project directory', ()=>{ + expect(skills_target(host_by_id('codex'), 'project', home, '/p')).toBe(path.join('/p', '.agents', 'skills')); + }); +}); + +describe('run_flat install', ()=>{ + it('copies every pack skill into the host skills directory', async()=>{ + const outcome = await run_flat(flat_opts('install')); + const target = path.join(home, '.cursor', 'skills'); + expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(target, 'agentic-runtime-skill', 'SKILL.md'))).toBe(true); + expect(outcome.status).toBe('ok'); + expect(outcome.packs?.map(p=>p.action)).toEqual(['installed', 'installed', 'installed']); + }); + + it('creates the skills directory when the host has none yet', async()=>{ + fs.rmSync(path.join(home, '.cursor', 'skills'), {recursive: true, force: true}); + await run_flat(flat_opts('install')); + expect(fs.existsSync(path.join(home, '.cursor', 'skills'))).toBe(true); + }); + + it('copies only the requested pack', async()=>{ + await run_flat(flat_opts('install', core_only)); + const target = path.join(home, '.cursor', 'skills'); + expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill'))).toBe(true); + expect(fs.existsSync(path.join(target, 'reply-adapter-skill'))).toBe(false); + }); + + it('journals the version, ref, commit and the files it wrote', async()=>{ + await run_flat(flat_opts('install', core_only)); + const entry = journal_entry('cursor', 'user', 'ai-sdr-core', env()); + expect(entry?.version).toBe('0.1.0'); + expect(entry?.commit).toBe('deadbee'); + expect(entry?.ref).toBe('main'); + expect(entry?.files.some(f=>f.endsWith('SKILL.md'))).toBe(true); + }); + + it('replaces on re-run instead of duplicating, and reports current', async()=>{ + await run_flat(flat_opts('install', core_only)); + const outcome = await run_flat(flat_opts('install', core_only)); + const target = path.join(home, '.cursor', 'skills'); + expect(fs.readdirSync(target)).toEqual(['ai-sdr-core-skill']); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + }); + + it('leaves a user-authored skill in the same directory byte-identical', async()=>{ + const target = path.join(home, '.cursor', 'skills'); + fs.mkdirSync(path.join(target, 'my-own-skill'), {recursive: true}); + const mine = path.join(target, 'my-own-skill', 'SKILL.md'); + fs.writeFileSync(mine, '---\nname: my-own-skill\ndescription: mine\n---\nkeep me\n'); + const before = fs.readFileSync(mine); + await run_flat(flat_opts('install')); + await run_flat(flat_opts('remove')); + expect(fs.readFileSync(mine)).toEqual(before); + }); + + it('changes nothing on --dry-run', async()=>{ + const outcome = await run_flat({...flat_opts('install'), dry_run: true}); + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'ai-sdr-core-skill'))).toBe(false); + expect(outcome.packs?.map(p=>p.action)).toEqual(['installed', 'installed', 'installed']); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); + }); + + it('fails the host with an actionable hint when cloning fails', async()=>{ + const outcome = await run_flat({ + ...flat_opts('install'), + clone: async()=>{ throw new Error('git not found'); }, + }); + expect(outcome.status).toBe('failed'); + expect(outcome.reason).toBe('clone-failed'); + expect(outcome.detail).toContain('git not found'); + expect(outcome.hint).toContain('git'); + }); +}); + +describe('run_flat remove and list', ()=>{ + it('deletes only journaled files and forgets the pack', async()=>{ + await run_flat(flat_opts('install', core_only)); + const outcome = await run_flat(flat_opts('remove', core_only)); + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'ai-sdr-core-skill'))).toBe(false); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'removed', version: '0.1.0'}]); + }); + + it('ignores a pack that was never installed', async()=>{ + const outcome = await run_flat(flat_opts('remove', core_only)); + expect(outcome.packs).toEqual([]); + }); + + it('lists what the journal says is installed without cloning', async()=>{ + await run_flat(flat_opts('install', core_only)); + const outcome = await run_flat({ + ...flat_opts('list'), + clone: async()=>{ throw new Error('list must not clone'); }, + }); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + }); + + it('update re-copies an installed pack and leaves an absent one alone', async()=>{ + await run_flat(flat_opts('install', core_only)); + const outcome = await run_flat(flat_opts('update', all)); + expect(outcome.packs?.map(p=>p.name)).toEqual(['ai-sdr-core']); + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'reply-adapter-skill'))).toBe(false); + }); +}); + +// C1: the journal must be consulted per scope, not per host+pack alone — a +// user-scope install and a project-scope install of the same pack on the +// same host must never be able to see, or delete, one another's files. +describe('run_flat scope isolation', ()=>{ + it('keeps a user-scope install and a project-scope install of the same pack independent', async()=>{ + await run_flat(flat_opts('install', core_only)); + const project_outcome = await run_flat({...flat_opts('install', core_only), scope: 'project'}); + const user_target = path.join(home, '.cursor', 'skills'); + const project_target = path.join(root, 'project', '.agents', 'skills'); + + // A first install under a different scope must actually copy, not + // silently report 'current' because the pack is already journaled + // under the other scope. + expect(project_outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}]); + expect(fs.existsSync(path.join(user_target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(project_target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + + const remove_outcome = await run_flat({...flat_opts('remove', core_only), scope: 'project'}); + expect(remove_outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'removed', version: '0.1.0'}]); + expect(fs.existsSync(path.join(project_target, 'ai-sdr-core-skill'))).toBe(false); + // Removing the project-scope install must not touch the user-scope one. + expect(fs.existsSync(path.join(user_target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + }); +}); + +// I1: a per-host filesystem failure during copy must become a Host_outcome, +// never a rejected promise, and packs that already landed before the failure +// must still be reported and journaled rather than orphaned. +describe('run_flat copy failures', ()=>{ + it('reports a failed status instead of rejecting when a per-pack copy throws, keeping earlier successes', async()=>{ + const broken_clone = async(): Promise<{dir: string; commit: string}>=>{ + const core_skills = path.join(clone_dir, 'plugins', 'ai-sdr-core', 'skills'); + fs.mkdirSync(path.join(core_skills, 'ai-sdr-core-skill'), {recursive: true}); + fs.writeFileSync(path.join(core_skills, 'ai-sdr-core-skill', 'SKILL.md'), '---\nname: x\n---\n'); + // Deliberately no plugins/reply-adapter/skills directory, so its + // readdirSync throws mid-loop. + return {dir: clone_dir, commit: 'deadbee'}; + }; + const outcome = await run_flat({...flat_opts('install', all), clone: broken_clone}); + expect(outcome.status).toBe('partial'); + expect(outcome.reason).toBe('copy-failed'); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}]); + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + }); + + it('copy_dir preserves files already written across a later failure, instead of discarding them', ()=>{ + // A directory-listing-order-independent way to pin the same contract + // the install loop relies on: copy_dir mutates the array it is given, + // so a throw from a second, unrelated call still leaves the first + // call's file visible to the caller rather than orphaned off-journal. + const good_from = path.join(root, 'good-src'); + fs.mkdirSync(good_from, {recursive: true}); + fs.writeFileSync(path.join(good_from, 'SKILL.md'), 'content'); + const to = path.join(root, 'dest'); + const written: string[] = []; + + copy_dir(good_from, path.join(to, 'skill-a'), written); + expect(written).toEqual([path.join(to, 'skill-a', 'SKILL.md')]); + + const missing_from = path.join(root, 'does-not-exist'); + expect(()=>copy_dir(missing_from, path.join(to, 'skill-b'), written)).toThrow(); + // The first call's entry must still be there after the second throws. + expect(written).toEqual([path.join(to, 'skill-a', 'SKILL.md')]); + }); +}); + +// I2: delete_files must only ever touch paths under the host's own skills +// directory, and must never remove the skills directory itself, even when +// the journal entry driving it is hand-edited or stale. +describe('run_flat deletion containment', ()=>{ + it('refuses a journaled path outside the skills directory and says so instead of claiming removed', async()=>{ + const outside = path.join(root, 'outside.txt'); + fs.writeFileSync(outside, 'do not touch'); + record_pack('cursor', 'user', 'ai-sdr-core', { + version: '0.1.0', ref: 'main', commit: 'deadbee', scope: 'user', + files: [outside], complete: true, installed_at: '2026-07-30T00:00:00.000Z', + }, env()); + const outcome = await run_flat(flat_opts('remove', core_only)); + expect(fs.existsSync(outside)).toBe(true); + // Nothing was deleted, so nothing may be reported as removed — and the + // entry stays, because forgetting it would strand the recorded files + // with nothing left tracking them. + expect(outcome.status).toBe('failed'); + expect(outcome.packs?.map(p=>[p.name, p.action])).toEqual([['ai-sdr-core', 'failed']]); + expect(outcome.packs?.[0].detail).toContain(outside); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + }); + + // Deferred minor promoted to must-fix: `fs.rmSync(file, {force: true})` + // does not clear the read-only attribute on Windows and throws EPERM. + // Swallowing that left the file on disk and the pack reported `removed`. + it('reports a file it could not delete instead of a false success', async()=>{ + await run_flat(flat_opts('install', core_only)); + const rm_spy = vi.spyOn(fs, 'rmSync').mockImplementationOnce(()=>{ + throw Object.assign(new Error('EPERM: operation not permitted, unlink'), {code: 'EPERM'}); + }); + try { + const outcome = await run_flat(flat_opts('remove', core_only)); + expect(outcome.status).toBe('failed'); + expect(outcome.packs?.map(p=>[p.name, p.action])).toEqual([['ai-sdr-core', 'failed']]); + expect(outcome.packs?.[0].detail).toContain('EPERM'); + } finally { + rm_spy.mockRestore(); + } + // Still tracked, so a later `remove` can retry it. + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + }); + + it('deletes a journaled file directly under the skills root without pruning the root itself', async()=>{ + const target = path.join(home, '.cursor', 'skills'); + fs.mkdirSync(target, {recursive: true}); + const direct = path.join(target, 'direct.txt'); + fs.writeFileSync(direct, 'x'); + record_pack('cursor', 'user', 'ai-sdr-core', { + version: '0.1.0', ref: 'main', commit: 'deadbee', scope: 'user', + files: [direct], complete: true, installed_at: '2026-07-30T00:00:00.000Z', + }, env()); + await run_flat(flat_opts('remove', core_only)); + expect(fs.existsSync(direct)).toBe(false); + expect(fs.existsSync(target)).toBe(true); + }); +}); + +// I3: the byte-identical guarantee also has to hold when the user's skill +// happens to share its name with one we ship, not just when the names differ. +describe('run_flat name collisions', ()=>{ + it('fails a pack instead of overwriting a user-authored skill with a colliding name', async()=>{ + const target = path.join(home, '.cursor', 'skills'); + fs.mkdirSync(path.join(target, 'ai-sdr-core-skill'), {recursive: true}); + const mine = path.join(target, 'ai-sdr-core-skill', 'SKILL.md'); + fs.writeFileSync(mine, '---\nname: mine\ndescription: not the pack\n---\nkeep me\n'); + const before = fs.readFileSync(mine); + + const outcome = await run_flat(flat_opts('install', core_only)); + + expect(outcome.packs).toEqual([{ + name: 'ai-sdr-core', action: 'failed', detail: 'conflicts with an existing skill: ai-sdr-core-skill', + }]); + expect(outcome.status).toBe('failed'); + expect(fs.readFileSync(mine)).toEqual(before); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); + // Nothing else depends on ai-sdr-core in this run, so nothing was + // blocked — the hint is specifically for blocked dependents, not for + // the failure itself. + expect(outcome.hint).toBeUndefined(); + }); +}); + +// Required fix, raised by the controller from a concern in the original +// self-review: adapter-native.ts never lets a dependent install while its +// dependency failed (failed_names/blocked_names). run_flat must enforce the +// same invariant — a pack that fails on a collision (I3) or a copy error (I1) +// must block anything depending on it, exactly like the native adapter. +describe('run_flat dependency blocking', ()=>{ + it('blocks every pack that depends on one that failed on a collision, and never copies them', 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'), + '---\nname: mine\ndescription: not the pack\n---\nkeep me\n', + ); + + const outcome = await run_flat(flat_opts('install', all)); + + // reply-adapter and agentic-runtime both depend on ai-sdr-core, so + // neither is attempted — blocked packs get no outcome entry at all, + // matching adapter-native.ts's blocked_names behavior. + expect(outcome.packs).toEqual([{ + name: 'ai-sdr-core', action: 'failed', detail: 'conflicts with an existing skill: ai-sdr-core-skill', + }]); + expect(outcome.status).toBe('failed'); + expect(outcome.hint).toContain('reply-adapter'); + expect(outcome.hint).toContain('agentic-runtime'); + expect(fs.existsSync(path.join(target, 'reply-adapter-skill'))).toBe(false); + expect(fs.existsSync(path.join(target, 'agentic-runtime-skill'))).toBe(false); + expect(journal_entry('cursor', 'user', 'reply-adapter', env())).toBeUndefined(); + expect(journal_entry('cursor', 'user', 'agentic-runtime', env())).toBeUndefined(); + }); +}); + +// C1 (final review): reverse removal order is necessary but not sufficient. +// Once delete_files can report a file it could not remove, a failed removal +// must stop its dependency being removed too, or the host is left holding an +// adapter with no core — the one state this installer exists to prevent. +describe('run_flat remove dependency guard', ()=>{ + it('keeps a dependency installed when removing a pack that depends on it failed', async()=>{ + await run_flat(flat_opts('install', all)); + const target = path.join(home, '.cursor', 'skills'); + + // Removal walks [agentic-runtime, reply-adapter, ai-sdr-core]; the + // first rmSync is agentic-runtime's only file. + const rm_spy = vi.spyOn(fs, 'rmSync').mockImplementationOnce(()=>{ + throw Object.assign(new Error('EPERM: operation not permitted, unlink'), {code: 'EPERM'}); + }); + let outcome; + try { + outcome = await run_flat(flat_opts('remove', all)); + } finally { + rm_spy.mockRestore(); + } + + expect(outcome.packs?.map(p=>[p.name, p.action])).toEqual([ + ['agentic-runtime', 'failed'], + ['reply-adapter', 'removed'], + ]); + expect(outcome.status).toBe('partial'); + expect(outcome.hint).toContain('ai-sdr-core'); + // The core survives on disk and in the journal, because a pack that + // depends on it is still installed. + expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + expect(journal_entry('cursor', 'user', 'agentic-runtime', env())?.version).toBe('0.1.0'); + expect(journal_entry('cursor', 'user', 'reply-adapter', env())).toBeUndefined(); + }); + + it('removes everything, in reverse order, when nothing fails', async()=>{ + await run_flat(flat_opts('install', all)); + const outcome = await run_flat(flat_opts('remove', all)); + expect(outcome.packs?.map(p=>[p.name, p.action])).toEqual([ + ['agentic-runtime', 'removed'], + ['reply-adapter', 'removed'], + ['ai-sdr-core', 'removed'], + ]); + expect(outcome.status).toBe('ok'); + expect(outcome.hint).toBeUndefined(); + expect(fs.readdirSync(path.join(home, '.cursor', 'skills'))).toEqual([]); + }); +}); + +// I2 (final review): a project-scope entry is keyed host -> 'project' -> pack, +// which cannot tell two checkouts apart. Without the project root on the +// entry, `remove --project` from a second repository deletes nothing (the +// containment check refuses every path) and still reports `removed`. +describe('run_flat project-scope entries belong to one repository', ()=>{ + const other_project = ()=>path.join(root, 'other-project'); + + it('leaves another repository\'s install alone and does not claim to have removed it', async()=>{ + await run_flat({...flat_opts('install', core_only), scope: 'project'}); + const installed_file = path.join(root, 'project', '.agents', 'skills', 'ai-sdr-core-skill', 'SKILL.md'); + expect(fs.existsSync(installed_file)).toBe(true); + expect(journal_entry('cursor', 'project', 'ai-sdr-core', env())?.project_root) + .toBe(path.resolve(path.join(root, 'project'))); + + // Same machine, same host, same journal — a different repository. + const outcome = await run_flat({ + ...flat_opts('remove', core_only), scope: 'project', cwd: other_project(), + }); + + expect(outcome.packs).toEqual([]); + expect(fs.existsSync(installed_file)).toBe(true); + // The first repository's entry is not this run's to forget. + expect(journal_entry('cursor', 'project', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + }); + + it('does not report another repository\'s install in a project-scope list', async()=>{ + await run_flat({...flat_opts('install', core_only), scope: 'project'}); + const outcome = await run_flat({ + ...flat_opts('list', core_only), scope: 'project', cwd: other_project(), + clone: async()=>{ throw new Error('list must not clone'); }, + }); + expect(outcome.packs).toEqual([]); + }); + + it('still removes a project install run from the repository that owns it', async()=>{ + await run_flat({...flat_opts('install', core_only), scope: 'project'}); + 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(); + }); + + it('leaves user-scope entries unkeyed and removable exactly as before', async()=>{ + await run_flat(flat_opts('install', core_only)); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.project_root).toBeUndefined(); + // A different cwd is irrelevant to a user-scope install. + const outcome = await run_flat({...flat_opts('remove', core_only), cwd: other_project()}); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'removed', version: '0.1.0'}]); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); + }); +}); + +// I4: several flat hosts resolve the same physical directory under --project +// (`.agents/skills`). Removing one host's install must not delete files a +// sibling host's install still claims, and must not silently invalidate that +// 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}); + await run_flat({...flat_opts('install', core_only), scope: 'project'}); + await run_flat({...flat_opts('install', core_only), scope: 'project', host: gemini()}); + + 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. + expect(fs.existsSync(shared)).toBe(true); + expect(journal_entry('gemini-cli', '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(), + clone: async()=>{ throw new Error('list must not clone'); }, + }); + expect(list_outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + }); +}); + +// I5: a host with no directory configured for the requested scope (a native +// host under `user` scope) must be skipped, not crashed on with a TypeError +// from joining `undefined` into a path. +describe('run_flat with no skills directory for the scope', ()=>{ + it('reports skipped instead of crashing', async()=>{ + const outcome = await run_flat({...flat_opts('install', core_only), host: codex(), scope: 'user'}); + expect(outcome.status).toBe('skipped'); + expect(outcome.reason).toBe('no-skills-dir'); + }); +}); + +// 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. +describe('clone_repo', ()=>{ + it('throws and removes its temp directory when `git clone` fails', async()=>{ + let captured_dir = ''; + const run: Runner = async(_bin, args)=>{ + if (args[0] === 'clone') + { + captured_dir = args[args.length - 1]; + return {code: 128, stdout: '', stderr: 'fatal: repository not found'}; + } + return {code: 0, stdout: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n', stderr: ''}; + }; + await expect(clone_repo({ref: 'main', run, tmp_root: root})).rejects.toThrow('repository not found'); + expect(captured_dir).not.toBe(''); + expect(fs.existsSync(captured_dir)).toBe(false); + }); + + it('throws and removes its temp directory when `git rev-parse HEAD` fails', async()=>{ + let captured_dir = ''; + const run: Runner = async(_bin, args)=>{ + if (args[0] === 'clone') + { + captured_dir = args[args.length - 1]; + return {code: 0, stdout: '', stderr: ''}; + } + return {code: 1, stdout: '', stderr: 'fatal: not a git repository'}; + }; + await expect(clone_repo({ref: 'main', run, tmp_root: root})).rejects.toThrow('not a git repository'); + expect(captured_dir).not.toBe(''); + expect(fs.existsSync(captured_dir)).toBe(false); + }); +}); + +// New Important from the re-review: a failed copy journaled under the target +// version reads as installed, so the hint's own suggested fix ("re-run +// install") silently does nothing. Journal_entry.complete distinguishes a +// finished copy from a partial one; `pending` and `list` must both treat an +// incomplete entry as work still to do, never as current. +describe('run_flat incomplete installs', ()=>{ + it('really re-copies a pack whose entry is marked incomplete, even at the target version', async()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', { + version: '0.1.0', ref: 'main', commit: 'deadbee', scope: 'user', + files: [], complete: false, installed_at: '2026-07-30T00:00:00.000Z', + }, env()); + + const outcome = await run_flat(flat_opts('install', core_only)); + + // The repair landed at the same version, so the action is `current` + // (I3: an unchanged version is never `upgraded`). What proves the copy + // actually ran — rather than the entry short-circuiting to `current` + // as it did before Journal_entry.complete existed — is the filesystem + // and the journal below, not the action. + expect(outcome.packs?.map(p=>({name: p.name, action: p.action}))).toEqual([ + {name: 'ai-sdr-core', action: 'current'}, + ]); + const target = path.join(home, '.cursor', 'skills'); + expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + const entry = journal_entry('cursor', 'user', 'ai-sdr-core', env()); + expect(entry?.complete).toBe(true); + expect(entry?.files.length).toBeGreaterThan(0); + // The version did not move, but a broken install became a working one: + // the files in front of the assistant are new, so the outcome has to + // say so or the reporter cannot advise a new session. + expect(outcome.packs?.[0].refreshed).toBe(true); + }); + + it('journals a copy failure as incomplete with only the files that actually landed, never the stale complete entry', async()=>{ + // A normal, fully successful install first. + await run_flat(flat_opts('install', core_only)); + const target = path.join(home, '.cursor', 'skills'); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.complete).toBe(true); + + // Force the very next file copy to throw before anything for this + // re-attempt lands (written.length === 0 at the point of failure) — + // portable and deterministic, unlike relying on a real permissions + // failure, and precedented in this codebase (see output.test.ts). + const copy_spy = vi.spyOn(fs, 'copyFileSync').mockImplementationOnce(()=>{ + throw new Error('simulated disk failure'); + }); + try { + const outcome = await run_flat(flat_opts('update', core_only)); + expect(outcome.status).toBe('failed'); + expect(outcome.reason).toBe('copy-failed'); + } finally { + copy_spy.mockRestore(); + } + + // The old entry (a different version's worth of files, all of which + // delete_files already removed) must not survive as if nothing + // happened: the pack must read as incomplete, not as the old, + // now-nonexistent install. + const entry = journal_entry('cursor', 'user', 'ai-sdr-core', env()); + expect(entry?.complete).toBe(false); + expect(entry?.files).toEqual([]); + // The old file is gone (delete_files already ran); the empty shell + // directory copy_dir recreated before the throw is not "the pack" — + // what matters is that no old content survives under the old entry. + expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(false); + }); + + it('does not report an incomplete pack as current in `list`', async()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', { + version: '0.1.0', ref: 'main', commit: 'deadbee', scope: 'user', + files: [], complete: false, installed_at: '2026-07-30T00:00:00.000Z', + }, env()); + + const outcome = await run_flat({ + ...flat_opts('list', core_only), + clone: async()=>{ throw new Error('list must not clone'); }, + }); + + expect(outcome.packs?.map(p=>p.action)).not.toContain('current'); + expect(outcome.status).not.toBe('ok'); + }); +}); + +// This adapter clones a *ref*, so a new commit on `main` can rewrite every +// file while the version stays 0.1.0. Reporting `current` for that is right — +// the version really did not move — but the outcome must still record that the +// bytes did, or a run that rewrote the user's files reads as a no-op. +describe('run_flat re-copy at an unchanged version', ()=>{ + // Same layout as fake_clone, different commit — as a second `update` a day + // later would see after the ref moved. + const clone_at = (commit: string)=>async()=>{ + await fake_clone(); + return {dir: clone_dir, commit}; + }; + + it('marks the pack refreshed when the ref moved but the version did not', async()=>{ + await run_flat({...flat_opts('install', core_only), clone: clone_at('aaaaaaa')}); + const outcome = await run_flat({...flat_opts('update', core_only), clone: clone_at('bbbbbbb')}); + + expect(outcome.packs).toEqual([{ + name: 'ai-sdr-core', action: 'current', version: '0.1.0', refreshed: true, + }]); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.commit).toBe('bbbbbbb'); + }); + + it('does not mark it refreshed when the same commit is re-copied', async()=>{ + await run_flat({...flat_opts('install', core_only), clone: clone_at('aaaaaaa')}); + const outcome = await run_flat({...flat_opts('update', core_only), clone: clone_at('aaaaaaa')}); + + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + }); + + it('never guesses on --dry-run, which does not clone', async()=>{ + await run_flat({...flat_opts('install', core_only), clone: clone_at('aaaaaaa')}); + const outcome = await run_flat({ + ...flat_opts('update', core_only), dry_run: true, + clone: async()=>{ throw new Error('a dry run must not clone'); }, + }); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + }); +}); + +// Minor, folded in because it sits in the code already being edited: the +// copy-error abort path used `outcomes.length` as a proxy for "something +// landed", which is wrong whenever every outcome recorded before the abort +// was itself a failure — it must check the outcomes' actions, not their count. +describe('run_flat abort status', ()=>{ + it('reports failed, not partial, when every outcome recorded before an abort had already failed', async()=>{ + const target = path.join(home, '.cursor', 'skills'); + // reply-adapter collides with a user-authored directory... + fs.mkdirSync(path.join(target, 'reply-adapter-skill'), {recursive: true}); + fs.writeFileSync( + path.join(target, 'reply-adapter-skill', 'SKILL.md'), + '---\nname: mine\ndescription: not the pack\n---\nkeep me\n', + ); + // ...and agentic-runtime (independent of reply-adapter, so not + // blocked by its failure) throws on its own copy. + const copy_spy = vi.spyOn(fs, 'copyFileSync').mockImplementationOnce(()=>{ + throw new Error('simulated disk failure'); + }); + try { + const outcome = await run_flat(flat_opts('install', adapter_and_runtime_only)); + expect(outcome.status).toBe('failed'); + expect(outcome.packs).toEqual([{ + name: 'reply-adapter', action: 'failed', detail: 'conflicts with an existing skill: reply-adapter-skill', + }]); + } finally { + copy_spy.mockRestore(); + } + }); +}); + +// owns_dir/protected_files once compared paths case-sensitively while +// is_within did not, so the two disagreed about a differently-cased path. +// They now share `path.relative`, whose case sensitivity deliberately follows +// the platform's filesystem: on Windows the two spellings are one file, on +// Linux they are two. Both readings are correct, so the expectation differs +// per platform rather than one of them being a bug — asserting a single +// answer here is what turned CI red on Linux while passing on Windows. +const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin'; + +describe('run_flat differently-cased journaled paths', ()=>{ + // `update`, not `install`: the entry is already complete at the target + // version, so `install` would skip the copy entirely without ever + // reaching the collision/ownership check these tests exercise. + const seed_differently_cased_entry = (): string=>{ + const target = path.join(home, '.cursor', 'skills'); + const differently_cased = path.join(target, 'AI-SDR-CORE-SKILL', 'SKILL.MD'); + record_pack('cursor', 'user', 'ai-sdr-core', { + version: '0.1.0', ref: 'main', commit: 'deadbee', scope: 'user', + files: [differently_cased], complete: true, installed_at: '2026-07-30T00:00:00.000Z', + }, env()); + fs.mkdirSync(path.join(target, 'ai-sdr-core-skill'), {recursive: true}); + fs.writeFileSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'), 'old content'); + return target; + }; + + it.skipIf(!CASE_INSENSITIVE_FS)('treats it as already ours where the filesystem ignores case', async()=>{ + const target = seed_differently_cased_entry(); + + const outcome = await run_flat(flat_opts('update', core_only)); + + // Re-copied from a fresh clone, but at the same version — so `current`, + // and the replaced content is proof the copy itself was not skipped. + 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'); + }); + + it.skipIf(CASE_INSENSITIVE_FS)('treats it as a foreign file where case distinguishes paths', async()=>{ + const target = seed_differently_cased_entry(); + + const outcome = await run_flat(flat_opts('update', core_only)); + + // On a case-sensitive filesystem the journaled path names a different + // file, so the one on disk is not ours and must not be overwritten. + 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('old content'); + }); +}); diff --git a/src/__tests__/skills/adapter-native.test.ts b/src/__tests__/skills/adapter-native.test.ts new file mode 100644 index 0000000..4d6fc54 --- /dev/null +++ b/src/__tests__/skills/adapter-native.test.ts @@ -0,0 +1,376 @@ +import {describe, it, expect, vi} from 'vitest'; +import {run_native, installed_versions} from '../../skills/adapter-native'; +import {host_by_id} from '../../skills/hosts'; +import {PACKS_FALLBACK, resolve_packs} from '../../skills/packs'; +import type {Detected_host} from '../../skills/detect'; +import type {Pack, Run_result, Runner} from '../../skills/types'; + +const ok = (stdout = ''): Run_result=>({code: 0, stdout, stderr: ''}); +const fail = (stderr = 'boom'): Run_result=>({code: 1, stdout: '', stderr}); + +const claude = (bin?: string): Detected_host=> + ({def: host_by_id('claude-code'), bin: bin ?? '/usr/bin/claude', config_dir: '/home/a/.claude'}); +const claude_no_bin = (): Detected_host=> + ({def: host_by_id('claude-code'), bin: undefined, config_dir: '/home/a/.claude'}); +const codex = (): Detected_host=> + ({def: host_by_id('codex'), bin: 'C:\\codex.exe', config_dir: 'C:\\Users\\a\\.codex'}); + +const all = resolve_packs([], PACKS_FALLBACK); +const core_only = resolve_packs(['core'], PACKS_FALLBACK); +const adapter = resolve_packs(['adapter'], PACKS_FALLBACK); + +// `claude plugin list --json` shape, trimmed to what the adapter reads. +const claude_list = (packs: {name: string; version: string}[]): string=>JSON.stringify({ + plugins: packs.map(p=>({name: p.name, marketplace: 'reply-skills', version: p.version, enabled: true})), +}); + +// Claude Code's *current* `plugin list --json` shape: a direct array of rows +// with an `id` of the form "@" instead of separate `name`/ +// `marketplace` fields. Captured for real from `claude plugin list --json` +// (Claude Code 2.1.220) on 2026-07-30, trimmed to the fields the adapter +// reads (`id`, `version`); the real output also carries `scope`, `enabled`, +// `installPath`, `installedAt`, `lastUpdated`, which installed_versions never +// looks at. Real row seen: {"id":"elastic-elasticsearch@elastic-agent-skills", +// "version":"0.2.4","scope":"user","enabled":true,...} — a plugin from a +// marketplace ('elastic-agent-skills') other than ours ('reply-skills'). +const claude_list_direct = (rows: {id: string; version: string}[]): string=>JSON.stringify( + rows.map(r=>({id: r.id, version: r.version, scope: 'user', enabled: true})), +); + +const runner_of = (results: Run_result[]): {run: Runner; calls: string[][]}=>{ + const calls: string[][] = []; + let i = 0; + const run: Runner = async(bin, args)=>{ + calls.push([bin, ...args]); + return results[i++] ?? ok(); + }; + return {run, calls}; +}; + +describe('installed_versions', ()=>{ + it('maps pack name to version from the host list', async()=>{ + const {run} = runner_of([ok(claude_list([{name: 'ai-sdr-core', version: '0.1.0'}]))]); + const result = await installed_versions(claude(), run); + expect(result).toEqual({ok: true, versions: {'ai-sdr-core': '0.1.0'}}); + }); + + it('returns failure when the host prints nothing usable', async()=>{ + const {run} = runner_of([ok('not json')]); + const result = await installed_versions(claude(), run); + expect(result).toEqual({ok: false}); + }); + + it('reads installed versions from the direct-array id shape Claude Code now returns', async()=>{ + const {run} = runner_of([ok(claude_list_direct([{id: 'ai-sdr-core@reply-skills', version: '0.1.0'}]))]); + const result = await installed_versions(claude(), run); + expect(result).toEqual({ok: true, versions: {'ai-sdr-core': '0.1.0'}}); + }); + + it('excludes a foreign-marketplace row in the direct-array id shape (real row, elastic-agent-skills)', async()=>{ + const {run} = runner_of([ok(claude_list_direct([ + {id: 'elastic-elasticsearch@elastic-agent-skills', version: '0.2.4'}, + ]))]); + const result = await installed_versions(claude(), run); + expect(result).toEqual({ok: true, versions: {}}); + }); +}); + +describe('run_native install', ()=>{ + it('registers the marketplace once, then installs core first', async()=>{ + const {run, calls} = runner_of([ok(), ok(claude_list([])), ok(), ok(), ok()]); + const outcome = await run_native({operation: 'install', host: claude(), packs: all, scope: 'user', run}); + expect(calls[0]).toEqual(['/usr/bin/claude', 'plugin', 'marketplace', 'add', 'reply-team/reply-skills']); + expect(calls.slice(2).map(c=>c[3])).toEqual([ + 'ai-sdr-core@reply-skills', 'reply-adapter@reply-skills', 'agentic-runtime@reply-skills', + ]); + expect(outcome.status).toBe('ok'); + expect(outcome.packs?.map(p=>p.action)).toEqual(['installed', 'installed', 'installed']); + }); + + it('passes the scope through', async()=>{ + const {run, calls} = runner_of([ok(), ok(claude_list([])), ok()]); + await run_native({operation: 'install', host: claude(), packs: core_only, scope: 'project', run}); + expect(calls[2]).toContain('--scope'); + expect(calls[2]).toContain('project'); + }); + + it('reports a pack already at the target version as current and runs no install', async()=>{ + const {run, calls} = runner_of([ok(), ok(claude_list([{name: 'ai-sdr-core', version: '0.1.0'}]))]); + const outcome = await run_native({operation: 'install', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + expect(calls).toHaveLength(2); + }); + + it('reports an older installed version as upgraded and records where it came from', async()=>{ + const {run} = runner_of([ok(), ok(claude_list([{name: 'ai-sdr-core', version: '0.0.9'}])), ok()]); + const outcome = await run_native({operation: 'install', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'upgraded', version: '0.1.0', from: '0.0.9'}]); + }); + + it('never attempts a dependent pack when its dependency failed', async()=>{ + const {run, calls} = runner_of([ok(), ok(claude_list([])), fail('core exploded')]); + const outcome = await run_native({operation: 'install', host: claude(), packs: adapter, scope: 'user', run}); + expect(calls).toHaveLength(3); + expect(outcome.status).toBe('failed'); + expect(outcome.packs?.map(p=>[p.name, p.action])).toEqual([['ai-sdr-core', 'failed']]); + expect(outcome.hint).toContain('reply-adapter'); + }); + + it('is partial when an independent pack fails but the core succeeded', async()=>{ + const {run} = runner_of([ok(), ok(claude_list([])), ok(), fail(), ok()]); + const outcome = await run_native({operation: 'install', host: claude(), packs: all, scope: 'user', run}); + expect(outcome.status).toBe('partial'); + expect(outcome.packs?.map(p=>p.action)).toEqual(['installed', 'failed', 'installed']); + }); + + it('skips a host whose binary could not be resolved, with a fix', async()=>{ + const {run, calls} = runner_of([]); + const outcome = await run_native({operation: 'install', host: claude_no_bin(), packs: all, scope: 'user', run}); + expect(outcome.status).toBe('skipped'); + expect(outcome.reason).toBe('cli-not-resolved'); + expect(outcome.hint).toContain('PATH'); + expect(calls).toEqual([]); + }); + + it('fails the host when marketplace registration fails', async()=>{ + const {run, calls} = runner_of([fail('no network')]); + const outcome = await run_native({operation: 'install', host: claude(), packs: all, scope: 'user', run}); + expect(outcome.status).toBe('failed'); + expect(outcome.detail).toContain('no network'); + expect(calls).toHaveLength(1); + }); + + it('uses the Codex verb spelling', async()=>{ + const {run, calls} = runner_of([ok('{}'), ok('{"installed":[]}'), ok('{}')]); + await run_native({operation: 'install', host: codex(), packs: core_only, scope: 'user', run}); + expect(calls[0]).toEqual(['C:\\codex.exe', 'plugin', 'marketplace', 'add', 'reply-team/reply-skills', '--json']); + expect(calls[2]).toEqual(['C:\\codex.exe', 'plugin', 'add', 'ai-sdr-core@reply-skills', '--json']); + }); + + it('reads installed versions from the Codex list shape', async()=>{ + const listing = JSON.stringify({installed: [{name: 'ai-sdr-core', version: '0.1.0', marketplaceName: 'reply-skills'}]}); + const {run} = runner_of([ok('{}'), ok(listing)]); + const outcome = await run_native({operation: 'install', host: codex(), packs: core_only, scope: 'user', run}); + expect(outcome.packs?.[0].action).toBe('current'); + }); + + it('changes nothing on --dry-run but still reports the plan', async()=>{ + const {run, calls} = runner_of([ok(claude_list([]))]); + const outcome = await run_native({ + operation: 'install', host: claude(), packs: core_only, scope: 'user', run, dry_run: true, + }); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}]); + expect(calls.map(c=>c.slice(1, 3))).toEqual([['plugin', 'list']]); + }); +}); + +describe('run_native remove', ()=>{ + it('removes dependents before their dependency', async()=>{ + const installed = claude_list([ + {name: 'ai-sdr-core', version: '0.1.0'}, {name: 'reply-adapter', version: '0.1.0'}, + ]); + const {run, calls} = runner_of([ok(installed), ok(), ok()]); + const outcome = await run_native({operation: 'remove', host: claude(), packs: adapter, scope: 'user', run}); + expect(calls.slice(1).map(c=>c[3])).toEqual(['reply-adapter@reply-skills', 'ai-sdr-core@reply-skills']); + expect(outcome.packs?.map(p=>p.action)).toEqual(['removed', 'removed']); + }); + + it('ignores a pack that is not installed', async()=>{ + const {run, calls} = runner_of([ok(claude_list([]))]); + const outcome = await run_native({operation: 'remove', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.packs).toEqual([]); + expect(calls).toHaveLength(1); + }); + + // C1 (final review): reverse order alone is not enough. A failed + // `plugin uninstall reply-adapter` followed by a successful + // `plugin uninstall ai-sdr-core` leaves the host with an adapter and no + // core — the state this installer exists to prevent. + it('never removes a dependency once removing a pack that depends on it failed', async()=>{ + const installed = claude_list([ + {name: 'ai-sdr-core', version: '0.1.0'}, {name: 'reply-adapter', version: '0.1.0'}, + ]); + const {run, calls} = runner_of([ok(installed), fail('file is locked')]); + const outcome = await run_native({operation: 'remove', host: claude(), packs: adapter, scope: 'user', run}); + // The listing, then the dependent's uninstall — and nothing else. + expect(calls.slice(1).map(c=>c[3])).toEqual(['reply-adapter@reply-skills']); + expect(outcome.packs).toEqual([ + {name: 'reply-adapter', action: 'failed', detail: 'file is locked'}, + ]); + expect(outcome.status).toBe('failed'); + expect(outcome.hint).toContain('ai-sdr-core'); + }); + + it('propagates the block down a dependency chain when the outermost removal failed', async()=>{ + const chain_a: Pack = {name: 'chainA', display_name: 'A', version: '1.0.0', description: '', dependencies: []}; + const chain_b: Pack = {name: 'chainB', display_name: 'B', version: '1.0.0', description: '', dependencies: ['chainA']}; + const chain_c: Pack = {name: 'chainC', display_name: 'C', version: '1.0.0', description: '', dependencies: ['chainB']}; + const listing = JSON.stringify({plugins: [chain_a, chain_b, chain_c].map(p=> + ({name: p.name, marketplace: 'reply-skills', version: p.version}))}); + const {run, calls} = runner_of([ok(listing), fail('C is locked')]); + const outcome = await run_native({ + operation: 'remove', host: claude(), packs: [chain_a, chain_b, chain_c], scope: 'user', run, + }); + expect(calls.filter(c=>c[2] === 'uninstall').map(c=>c[3])).toEqual(['chainC@reply-skills']); + expect(outcome.hint).toContain('chainB'); + expect(outcome.hint).toContain('chainA'); + }); + + it('reports no hint and removes everything when nothing fails', async()=>{ + const installed = claude_list([ + {name: 'ai-sdr-core', version: '0.1.0'}, {name: 'reply-adapter', version: '0.1.0'}, + ]); + const {run} = runner_of([ok(installed), ok(), ok()]); + const outcome = await run_native({operation: 'remove', host: claude(), packs: adapter, scope: 'user', run}); + expect(outcome.packs?.map(p=>p.action)).toEqual(['removed', 'removed']); + expect(outcome.status).toBe('ok'); + expect(outcome.hint).toBeUndefined(); + }); +}); + +describe('run_native list and update', ()=>{ + it('list reports installed versions without mutating anything', async()=>{ + const {run, calls} = runner_of([ok(claude_list([{name: 'ai-sdr-core', version: '0.0.9'}]))]); + const outcome = await run_native({operation: 'list', host: claude(), packs: all, scope: 'user', run}); + expect(calls).toHaveLength(1); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'upgraded', version: '0.1.0', from: '0.0.9'}]); + }); + + it('update runs the host update verb only for installed packs', async()=>{ + const {run, calls} = runner_of([ok(), ok(claude_list([{name: 'ai-sdr-core', version: '0.0.9'}])), ok()]); + const outcome = await run_native({operation: 'update', host: claude(), packs: all, scope: 'user', run}); + expect(calls[0]).toEqual(['/usr/bin/claude', 'plugin', 'marketplace', 'add', 'reply-team/reply-skills']); + expect(calls[2]).toEqual(['/usr/bin/claude', 'plugin', 'update', 'ai-sdr-core@reply-skills']); + expect(outcome.packs?.map(p=>p.name)).toEqual(['ai-sdr-core']); + }); + + it('update on Codex fires marketplace upgrade once for all installed packs', async()=>{ + const listing_before = JSON.stringify({installed: [ + {name: 'ai-sdr-core', version: '0.0.9', marketplaceName: 'reply-skills'}, + {name: 'reply-adapter', version: '0.0.8', marketplaceName: 'reply-skills'}, + {name: 'agentic-runtime', version: '0.0.7', marketplaceName: 'reply-skills'}, + ]}); + const listing_after = JSON.stringify({installed: [ + {name: 'ai-sdr-core', version: '0.1.0', marketplaceName: 'reply-skills'}, + {name: 'reply-adapter', version: '0.1.0', marketplaceName: 'reply-skills'}, + {name: 'agentic-runtime', version: '0.1.0', marketplaceName: 'reply-skills'}, + ]}); + const {run, calls} = runner_of([ok('{}'), ok(listing_before), ok('{}'), ok(listing_after)]); + const outcome = await run_native({operation: 'update', host: codex(), packs: all, scope: 'user', run}); + expect(calls.filter(c=>c[1] === 'plugin' && c[2] === 'marketplace' && c[3] === 'upgrade')).toHaveLength(1); + expect(outcome.packs?.map(p=>[p.name, p.action])).toEqual([['ai-sdr-core', 'upgraded'], ['reply-adapter', 'upgraded'], ['agentic-runtime', 'upgraded']]); + }); + + // I3 (final review): the marketplace path exits 0 whether or not anything + // moved, and used to hardcode `upgraded`. On a machine with Claude Code + // and Codex that printed "already current" and "updated" for one fact. + it('update on Codex reports current when the marketplace upgrade moved nothing', async()=>{ + const listing = JSON.stringify({installed: [ + {name: 'ai-sdr-core', version: '0.1.0', marketplaceName: 'reply-skills'}, + {name: 'reply-adapter', version: '0.1.0', marketplaceName: 'reply-skills'}, + ]}); + // Same versions before and after — the upgrade succeeded, nothing moved. + const {run} = runner_of([ok('{}'), ok(listing), ok('{}'), ok(listing)]); + const outcome = await run_native({operation: 'update', host: codex(), packs: adapter, scope: 'user', run}); + expect(outcome.packs).toEqual([ + {name: 'ai-sdr-core', action: 'current', version: '0.1.0'}, + {name: 'reply-adapter', action: 'current', version: '0.1.0'}, + ]); + }); + + it('update on Codex reports current on --dry-run when every pack is already at the target version', async()=>{ + const listing = JSON.stringify({installed: [ + {name: 'ai-sdr-core', version: '0.1.0', marketplaceName: 'reply-skills'}, + ]}); + // A dry run registers no marketplace, so the listing is the first call. + const {run} = runner_of([ok(listing)]); + const outcome = await run_native({ + operation: 'update', host: codex(), packs: core_only, scope: 'user', run, dry_run: true, + }); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + }); + + it('update reports current when the host update verb exits 0 without moving the version', async()=>{ + // Registry target 0.1.0, host on 0.0.9, `plugin update` succeeds but + // the post-update listing still says 0.0.9 — nothing changed, so the + // report must not claim an upgrade happened. + const stale = claude_list([{name: 'ai-sdr-core', version: '0.0.9'}]); + const {run} = runner_of([ok(), ok(stale), ok(), ok(stale)]); + const outcome = await run_native({operation: 'update', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.0.9'}]); + }); + + it('update reports current for pack already at target version', async()=>{ + const {run, calls} = runner_of([ok(), ok(claude_list([{name: 'ai-sdr-core', version: '0.1.0'}])), ok(claude_list([{name: 'ai-sdr-core', version: '0.1.0'}]))]); + const outcome = await run_native({operation: 'update', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); + expect(calls).toHaveLength(2); + }); + + it('failed list on remove returns failed status with list-failed reason', async()=>{ + const {run, calls} = runner_of([fail('network error')]); + const outcome = await run_native({operation: 'remove', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.status).toBe('failed'); + expect(outcome.reason).toBe('list-failed'); + expect(outcome.detail).toContain('network error'); + expect(calls).toHaveLength(1); + }); + + it('failed list on update returns failed status with list-failed reason', async()=>{ + const {run, calls} = runner_of([ok(), fail('network error')]); + const outcome = await run_native({operation: 'update', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.status).toBe('failed'); + expect(outcome.reason).toBe('list-failed'); + expect(calls).toHaveLength(2); + }); + + it('failed list on list returns failed status with list-failed reason', async()=>{ + const {run, calls} = runner_of([fail('network error')]); + const outcome = await run_native({operation: 'list', host: claude(), packs: all, scope: 'user', run}); + expect(outcome.status).toBe('failed'); + expect(outcome.reason).toBe('list-failed'); + expect(calls).toHaveLength(1); + }); + + it('plugin from different marketplace is treated as installed, not current', async()=>{ + const other_marketplace_list = JSON.stringify({plugins: [{name: 'ai-sdr-core', version: '0.1.0', marketplace: 'someone-else', enabled: true}]}); + const {run} = runner_of([ok(), ok(other_marketplace_list)]); + const outcome = await run_native({operation: 'install', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}]); + }); + + it('same pack name from a different marketplace, in the direct-array id shape, is treated as installed, not current', async()=>{ + // Same pack name as ours but a foreign marketplace suffix on the id — + // marketplace filtering must still apply in the new shape, not just the + // old {plugins:[...]} envelope. + const other_marketplace_list = claude_list_direct([{id: 'ai-sdr-core@someone-else', version: '0.1.0'}]); + const {run} = runner_of([ok(), ok(other_marketplace_list)]); + const outcome = await run_native({operation: 'install', host: claude(), packs: core_only, scope: 'user', run}); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}]); + }); + + it('transitive dependency chain: A fails, B depends on A, C depends on B', async()=>{ + // Build a synthetic three-pack chain: chainA -> chainB -> chainC + const chainA: Pack = {name: 'chainA', display_name: 'Chain A', version: '1.0.0', description: '', dependencies: []}; + const chainB: Pack = {name: 'chainB', display_name: 'Chain B', version: '1.0.0', description: '', dependencies: ['chainA']}; + const chainC: Pack = {name: 'chainC', display_name: 'Chain C', version: '1.0.0', description: '', dependencies: ['chainB']}; + const chain_packs = [chainA, chainB, chainC]; + const {run, calls} = runner_of([ok(), ok(claude_list([])), fail('A failed')]); + const outcome = await run_native({operation: 'install', host: claude(), packs: chain_packs, scope: 'user', run}); + // Should have calls: marketplace add, list json, chainA install. No chainB or chainC install. + const install_calls = calls.filter(c=>c[2] === 'install'); + expect(install_calls).toHaveLength(1); + expect(install_calls[0][3]).toEqual('chainA@reply-skills'); + expect(outcome.status).toBe('failed'); + expect(outcome.hint).toContain('chainB'); + expect(outcome.hint).toContain('chainC'); + }); + + it('failure that blocks nothing produces no hint', async()=>{ + // reply-adapter fails but nothing depends on it, so no hint + const {run} = runner_of([ok(), ok(claude_list([])), ok(), fail(), ok()]); + const outcome = await run_native({operation: 'install', host: claude(), packs: all, scope: 'user', run}); + expect(outcome.status).toBe('partial'); + expect(outcome.hint).toBeUndefined(); + }); +}); diff --git a/src/__tests__/skills/detect.test.ts b/src/__tests__/skills/detect.test.ts new file mode 100644 index 0000000..e7c76c3 --- /dev/null +++ b/src/__tests__/skills/detect.test.ts @@ -0,0 +1,142 @@ +import {describe, it, expect} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {detect_hosts, select_hosts, default_detect_deps, type Detect_deps} from '../../skills/detect'; +import {UsageError} from '../../utils/errors'; + +// A fake home directory: only the listed relative paths exist. +const deps_with = (present: string[], on_path: string[] = [], absolute: string[] = []): Detect_deps=>{ + const home = path.join(os.tmpdir(), 'fake-home'); + const exists_set = new Set(present.map(p=>path.join(home, p)).concat(absolute)); + return { + home, + platform: 'linux', + exists: (p: string)=>exists_set.has(p), + find_on_path: (name: string)=>on_path.includes(name) ? `/usr/bin/${name}` : undefined, + glob_first: (pattern: string)=>absolute.find(a=>a.startsWith(pattern.split('*')[0])), + }; +}; + +describe('detect_hosts', ()=>{ + it('finds nothing on an empty machine', ()=>{ + expect(detect_hosts(deps_with([]))).toEqual([]); + }); + + it('detects a native host whose config dir and binary are both present', ()=>{ + const found = detect_hosts(deps_with(['.claude'], ['claude'])); + expect(found.map(h=>h.def.id)).toEqual(['claude-code']); + expect(found[0].bin).toBe('/usr/bin/claude'); + const home = path.join(os.tmpdir(), 'fake-home'); + expect(found[0].config_dir).toBe(path.join(home, '.claude')); + }); + + it('detects a native host with no resolvable binary and leaves bin unset', ()=>{ + const found = detect_hosts(deps_with(['.codex'])); + expect(found.map(h=>h.def.id)).toEqual(['codex']); + expect(found[0].bin).toBeUndefined(); + }); + + it('resolves a binary that is off PATH via binary_paths', ()=>{ + const abs = path.join(os.tmpdir(), 'fake-home', 'AppData', 'Local', 'OpenAI', 'Codex', 'bin', 'abc', 'codex.exe'); + const found = detect_hosts(deps_with(['.codex'], [], [abs])); + expect(found[0].bin).toBe(abs); + }); + + it('detects a flat host from its config dir alone', ()=>{ + expect(detect_hosts(deps_with(['.cursor'])).map(h=>h.def.id)).toEqual(['cursor']); + }); + + it('detects several hosts in registry order', ()=>{ + const found = detect_hosts(deps_with(['.claude', '.codex', '.cursor'], ['claude'])); + expect(found.map(h=>h.def.id)).toEqual(['claude-code', 'codex', 'cursor']); + }); + + it('detects Windsurf through its nested config dir', ()=>{ + expect(detect_hosts(deps_with([path.join('.codeium', 'windsurf')])).map(h=>h.def.id)).toEqual(['windsurf']); + }); +}); + +describe('select_hosts', ()=>{ + it('returns everything detected when no ids are given', ()=>{ + const {selected, missing} = select_hosts(undefined, deps_with(['.claude'], ['claude'])); + expect(selected.map(h=>h.def.id)).toEqual(['claude-code']); + expect(missing).toEqual([]); + }); + + it('narrows to the requested ids', ()=>{ + const {selected} = select_hosts(['codex'], deps_with(['.claude', '.codex'], ['claude', 'codex'])); + expect(selected.map(h=>h.def.id)).toEqual(['codex']); + }); + + it('reports a requested host that is not present as missing', ()=>{ + const {selected, missing} = select_hosts(['cursor'], deps_with(['.claude'], ['claude'])); + expect(selected).toEqual([]); + expect(missing.map(h=>h.id)).toEqual(['cursor']); + }); + + it('rejects an unknown id', ()=>{ + expect(()=>select_hosts(['nope'], deps_with([]))).toThrow(UsageError); + }); +}); + +describe('default_detect_deps', ()=>{ + it('probes the real filesystem and reports a directory that exists', ()=>{ + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-detect-')); + try { + const deps = default_detect_deps(); + expect(deps.exists(dir)).toBe(true); + expect(deps.exists(path.join(dir, 'nope'))).toBe(false); + expect(deps.home.length).toBeGreaterThan(0); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('glob_first expands a wildcard to resolve off-PATH binaries', ()=>{ + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-glob-')); + try { + // Build: /AppData/Local/OpenAI/Codex/bin/abc123hash/codex.exe + const binDir = path.join(root, 'AppData', 'Local', 'OpenAI', 'Codex', 'bin', 'abc123hash'); + fs.mkdirSync(binDir, {recursive: true}); + const exePath = path.join(binDir, 'codex.exe'); + fs.writeFileSync(exePath, ''); + + const deps = default_detect_deps(); + const pattern = path.join(root, 'AppData', 'Local', 'OpenAI', 'Codex', 'bin', '*', 'codex.exe'); + expect(deps.glob_first(pattern)).toBe(exePath); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('glob_first returns undefined when no entry in the wildcard directory matches the tail', ()=>{ + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-glob-')); + try { + // Build: /bin/abc123hash/other.exe (no codex.exe) + const binDir = path.join(root, 'bin', 'abc123hash'); + fs.mkdirSync(binDir, {recursive: true}); + fs.writeFileSync(path.join(binDir, 'other.exe'), ''); + + const deps = default_detect_deps(); + const pattern = path.join(root, 'bin', '*', 'codex.exe'); + expect(deps.glob_first(pattern)).toBeUndefined(); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('glob_first treats a pattern with no wildcard as a plain existence check', ()=>{ + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-glob-')); + try { + const exePath = path.join(root, 'codex.exe'); + fs.writeFileSync(exePath, ''); + + const deps = default_detect_deps(); + expect(deps.glob_first(exePath)).toBe(exePath); + expect(deps.glob_first(path.join(root, 'nope.exe'))).toBeUndefined(); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); +}); diff --git a/src/__tests__/skills/hosts.test.ts b/src/__tests__/skills/hosts.test.ts new file mode 100644 index 0000000..42f6ca2 --- /dev/null +++ b/src/__tests__/skills/hosts.test.ts @@ -0,0 +1,66 @@ +import {describe, it, expect} from 'vitest'; +import {HOSTS, host_by_id, host_ids} from '../../skills/hosts'; +import {UsageError} from '../../utils/errors'; + +describe('host registry', ()=>{ + it('has unique ids', ()=>{ + expect(new Set(host_ids()).size).toBe(HOSTS.length); + }); + + it('covers the hosts v1 promises', ()=>{ + expect(host_ids()).toEqual(expect.arrayContaining([ + 'claude-code', 'codex', 'cursor', 'gemini-cli', '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']); + }); + + it('gives every native host a CLI and every flat host a skills directory', ()=>{ + for (const host of HOSTS) + { + if (host.kind === 'native-plugin') + { + expect(host.cli, host.id).toBeDefined(); + expect(host.binaries.length, host.id).toBeGreaterThan(0); + } + else + { + expect(host.user_skills_dir, host.id).toBeDefined(); + } + // Every host needs a project target: flat hosts use theirs directly, + // native hosts fall back to it when --project cannot be expressed. + expect(host.project_skills_dir, host.id).toBeDefined(); + } + }); + + it('builds Claude Code argument vectors', ()=>{ + const cli = host_by_id('claude-code').cli!; + expect(cli.marketplace_add('reply-team/reply-skills')) + .toEqual(['plugin', 'marketplace', 'add', 'reply-team/reply-skills']); + expect(cli.install('ai-sdr-core', 'reply-skills', 'user')) + .toEqual(['plugin', 'install', 'ai-sdr-core@reply-skills', '--scope', 'user']); + expect(cli.remove('ai-sdr-core', 'reply-skills')).toEqual(['plugin', 'uninstall', 'ai-sdr-core@reply-skills']); + expect(cli.list_json()).toEqual(['plugin', 'list', '--json']); + }); + + it('builds Codex argument vectors, which spell the verbs differently', ()=>{ + const cli = host_by_id('codex').cli!; + expect(cli.marketplace_add('reply-team/reply-skills')) + .toEqual(['plugin', 'marketplace', 'add', 'reply-team/reply-skills', '--json']); + expect(cli.install('ai-sdr-core', 'reply-skills', 'user')) + .toEqual(['plugin', 'add', 'ai-sdr-core@reply-skills', '--json']); + expect(cli.remove('ai-sdr-core', 'reply-skills')).toEqual(['plugin', 'remove', 'ai-sdr-core@reply-skills', '--json']); + }); + + it('knows Codex ships off PATH on Windows', ()=>{ + expect(host_by_id('codex').binary_paths.join(' ')).toContain('OpenAI'); + }); + + it('rejects an unknown host id with a usage error listing the known ones', ()=>{ + expect(()=>host_by_id('nope')).toThrow(UsageError); + try { host_by_id('nope'); } + catch (e) { expect((e as UsageError).hint).toContain('claude-code'); } + }); +}); diff --git a/src/__tests__/skills/journal.test.ts b/src/__tests__/skills/journal.test.ts new file mode 100644 index 0000000..f5cba5b --- /dev/null +++ b/src/__tests__/skills/journal.test.ts @@ -0,0 +1,97 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {read_journal, record_pack, forget_pack, journal_entry, type Journal_entry} from '../../skills/journal'; +import {skills_file} from '../../config'; +import {RuntimeError} from '../../utils/errors'; + +let dir: string; +const env = ()=>({REPLY_CONFIG_DIR: dir}); + +const entry = (version = '0.1.0', files: string[] = ['a/SKILL.md']): Journal_entry=>({ + version, ref: 'main', commit: 'abc1234', scope: 'user', files, complete: true, installed_at: '2026-07-30T00:00:00.000Z', +}); + +beforeEach(()=>{ + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-journal-')); +}); +afterEach(()=>{ + fs.rmSync(dir, {recursive: true, force: true}); +}); + +describe('skills journal', ()=>{ + it('reads an empty journal before anything is written', ()=>{ + expect(read_journal(env())).toEqual({version: 1, hosts: {}}); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); + }); + + it('records and reads back an entry', ()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', entry(), env()); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toEqual(entry()); + }); + + it('writes the journal next to the other config files', ()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', entry(), env()); + expect(fs.existsSync(skills_file(env()))).toBe(true); + expect(skills_file(env())).toBe(path.join(dir, 'skills.json')); + }); + + it('replaces an entry on re-record instead of duplicating it', ()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', entry('0.1.0'), env()); + record_pack('cursor', 'user', 'ai-sdr-core', entry('0.2.0'), env()); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.2.0'); + expect(Object.keys(read_journal(env()).hosts.cursor.user)).toEqual(['ai-sdr-core']); + }); + + 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()); + 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'); + }); + + it('keeps scopes isolated on the same host', ()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', entry('0.1.0'), env()); + record_pack('cursor', 'project', '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('cursor', 'project', 'ai-sdr-core', env())?.version).toBe('0.9.0'); + forget_pack('cursor', 'project', 'ai-sdr-core', env()); + expect(journal_entry('cursor', 'project', 'ai-sdr-core', env())).toBeUndefined(); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + }); + + it('forget_pack returns the entry it removed and is idempotent', ()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', entry(), env()); + expect(forget_pack('cursor', 'user', 'ai-sdr-core', env())?.files).toEqual(['a/SKILL.md']); + expect(forget_pack('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); + }); + + it('drops the host key once its last pack is forgotten', ()=>{ + record_pack('cursor', 'user', 'ai-sdr-core', entry(), env()); + forget_pack('cursor', 'user', 'ai-sdr-core', env()); + expect(read_journal(env()).hosts.cursor).toBeUndefined(); + }); + + it('treats an empty file as an empty journal', ()=>{ + fs.writeFileSync(path.join(dir, 'skills.json'), ''); + expect(read_journal(env())).toEqual({version: 1, hosts: {}}); + }); + + it('throws a RuntimeError on a corrupt journal (invalid JSON)', ()=>{ + fs.writeFileSync(path.join(dir, 'skills.json'), '{ not json'); + expect(()=>read_journal(env())).toThrow(RuntimeError); + }); + + it('throws a RuntimeError on a corrupt journal (unexpected shape)', ()=>{ + fs.writeFileSync(path.join(dir, 'skills.json'), '{"hosts": "nope"}'); + expect(()=>read_journal(env())).toThrow(RuntimeError); + }); + + it('throws a RuntimeError on read errors other than ENOENT', ()=>{ + const file_path = path.join(dir, 'skills.json'); + // Create a directory at the file path to trigger EISDIR on readFileSync + fs.mkdirSync(file_path); + expect(()=>read_journal(env())).toThrow(RuntimeError); + }); +}); diff --git a/src/__tests__/skills/orchestrate.test.ts b/src/__tests__/skills/orchestrate.test.ts new file mode 100644 index 0000000..dc58ba1 --- /dev/null +++ b/src/__tests__/skills/orchestrate.test.ts @@ -0,0 +1,295 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {run_skills} from '../../skills/orchestrate'; +import {human_lines} from '../../skills/report'; +import type {Detect_deps} from '../../skills/detect'; +import type {Runner} from '../../skills/types'; +import {UsageError} from '../../utils/errors'; + +let root: string; +let home: string; + +// A machine with Claude Code (native, on PATH) and Cursor (flat). +const detect_deps = (): Detect_deps=>({ + home, + platform: 'linux', + exists: (p: string)=>fs.existsSync(p), + find_on_path: (name: string)=>name === 'claude' ? '/usr/bin/claude' : undefined, + glob_first: ()=>undefined, +}); + +const calls: string[][] = []; +const run: Runner = async(bin, args)=>{ + calls.push([bin, ...args]); + if (args.includes('list')) + { + return {code: 0, stdout: JSON.stringify({plugins: []}), stderr: ''}; + } + return {code: 0, stdout: '', stderr: ''}; +}; + +const fake_clone = async()=>{ + const dir = fs.mkdtempSync(path.join(root, 'clone-')); + for (const name of ['ai-sdr-core', 'reply-adapter', 'agentic-runtime']) + { + const skills = path.join(dir, 'plugins', name, 'skills', `${name}-skill`); + fs.mkdirSync(skills, {recursive: true}); + fs.writeFileSync(path.join(skills, 'SKILL.md'), `---\nname: ${name}-skill\ndescription: d\n---\n`); + } + return {dir, commit: 'cafe123'}; +}; + +const opts = (over: Record = {})=>({ + operation: 'install' as const, + requested: [] as string[], + project: false, + dry_run: false, + deps: { + detect: detect_deps(), + run, + clone: fake_clone, + home, + cwd: path.join(root, 'project'), + tmp_root: root, + env: {REPLY_CONFIG_DIR: path.join(root, 'config')}, + fetch_impl: vi.fn().mockRejectedValue(new TypeError('offline')) as unknown as typeof fetch, + }, + ...over, +}); + +beforeEach(()=>{ + calls.length = 0; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-orch-')); + home = path.join(root, 'home'); + fs.mkdirSync(path.join(home, '.claude'), {recursive: true}); + fs.mkdirSync(path.join(home, '.cursor'), {recursive: true}); +}); +afterEach(()=>{ + fs.rmSync(root, {recursive: true, force: true}); +}); + +describe('run_skills', ()=>{ + it('installs all three packs into both host classes', async()=>{ + const report = await run_skills(opts()); + expect(report.hosts.map(h=>h.host)).toEqual(['claude-code', 'cursor']); + expect(report.resolved).toEqual(['ai-sdr-core', 'reply-adapter', 'agentic-runtime']); + expect(report.hosts.every(h=>h.status === 'ok')).toBe(true); + expect(report.summary).toEqual({installed: 2, skipped: 0, failed: 0}); + // Native host went through its own CLI… + expect(calls[0]).toEqual(['/usr/bin/claude', 'plugin', 'marketplace', 'add', 'reply-team/reply-skills']); + // …and the flat host got real files. + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + }); + + it('records requested separately from resolved on a selective install', async()=>{ + const report = await run_skills(opts({requested: ['adapter']})); + expect(report.requested).toEqual(['reply-adapter']); + expect(report.resolved).toEqual(['ai-sdr-core', 'reply-adapter']); + }); + + it('narrows to one host with --agent', async()=>{ + const report = await run_skills(opts({agents: ['cursor']})); + expect(report.hosts.map(h=>h.host)).toEqual(['cursor']); + }); + + it('includes a requested host that is not present, as skipped', async()=>{ + const report = await run_skills(opts({agents: ['windsurf']})); + expect(report.hosts).toEqual([expect.objectContaining({ + host: 'windsurf', status: 'skipped', reason: 'not-detected', + })]); + expect(report.summary).toEqual({installed: 0, skipped: 1, failed: 0}); + }); + + it('rejects an unknown --agent id', async()=>{ + await expect(run_skills(opts({agents: ['nope']}))).rejects.toThrow(UsageError); + }); + + it('rejects an unknown pack name', async()=>{ + await expect(run_skills(opts({requested: ['ghost']}))).rejects.toThrow(UsageError); + }); + + it('keeps a native host on its own CLI under --project when it can express project scope', async()=>{ + const report = await run_skills(opts({agents: ['claude-code'], project: true})); + expect(report.hosts[0].scope).toBe('project'); + // Claude Code expresses project scope natively, so it stays native. + expect(calls.some(c=>c.includes('--scope') && c.includes('project'))).toBe(true); + }); + + it('routes Codex through the flat adapter under --project, and reports scope honestly', async()=>{ + // Codex's plugin mechanism is user-scoped only, so a project-scoped run + // must fall back to the flat adapter (.agents/skills), never run_native, + // and the outcome must still say 'project' rather than silently + // dropping the request back to 'user'. + fs.mkdirSync(path.join(home, '.codex'), {recursive: true}); + const base_deps = opts().deps; + const report = await run_skills(opts({ + agents: ['codex'], + project: true, + deps: { + ...base_deps, + detect: { + home, + platform: 'linux', + exists: (p: string)=>fs.existsSync(p), + find_on_path: (name: string)=>name === 'codex' ? '/usr/bin/codex' : undefined, + glob_first: ()=>undefined, + }, + }, + })); + expect(report.hosts.map(h=>h.host)).toEqual(['codex']); + expect(report.hosts[0].kind).toBe('flat-skills-dir'); + expect(report.hosts[0].scope).toBe('project'); + // No native CLI call ever reached the Codex binary. + expect(calls.some(c=>c[0] === '/usr/bin/codex')).toBe(false); + expect(fs.existsSync( + path.join(root, 'project', '.agents', 'skills', 'ai-sdr-core-skill', 'SKILL.md'), + )).toBe(true); + }); + + it('reports the source ref and the resolved commit for flat installs', async()=>{ + const report = await run_skills(opts({agents: ['cursor']})); + expect(report.source).toEqual({repo: 'reply-team/reply-skills', ref: 'main', commit: 'cafe123'}); + }); + + it('changes nothing on --dry-run', async()=>{ + const report = await run_skills(opts({dry_run: true})); + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'ai-sdr-core-skill'))).toBe(false); + expect(calls.some(c=>c.includes('install'))).toBe(false); + expect(report.hosts.every(h=>h.status === 'ok')).toBe(true); + }); + + it('refuses to remove a pack that another pack depends on', async()=>{ + await run_skills(opts({agents: ['cursor']})); + await expect(run_skills(opts({operation: 'remove', requested: ['core'], agents: ['cursor']}))) + .rejects.toThrow(UsageError); + }); + + it('allows removing a pack nothing depends on', async()=>{ + await run_skills(opts({agents: ['cursor']})); + const report = await run_skills(opts({operation: 'remove', requested: ['runtime'], agents: ['cursor']})); + expect(report.hosts[0].packs?.map(p=>p.name)).toEqual(['agentic-runtime']); + }); + + it('removes everything when remove is given no pack', async()=>{ + await run_skills(opts({agents: ['cursor']})); + const report = await run_skills(opts({operation: 'remove', agents: ['cursor']})); + expect(report.hosts[0].packs?.map(p=>p.action)).toEqual(['removed', 'removed', 'removed']); + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'ai-sdr-core-skill'))).toBe(false); + }); + + it('keeps other hosts working when one host throws unexpectedly', async()=>{ + // run_native has no try/catch of its own around its process calls, so + // an injected Runner that rejects escapes it — exactly the kind of + // surprise (a journal write racing an antivirus scanner, a flaky + // network call) the orchestrator itself must contain per host. + const flaky_run: Runner = async(bin, args)=>{ + if (bin === '/usr/bin/claude') + { + throw new Error('ECONNRESET'); + } + return run(bin, args); + }; + const report = await run_skills(opts({ + agents: ['claude-code', 'cursor'], + deps: {...opts().deps, run: flaky_run}, + })); + expect(report.hosts.map(h=>h.host)).toEqual(['claude-code', 'cursor']); + expect(report.hosts[0]).toEqual(expect.objectContaining({ + host: 'claude-code', status: 'failed', reason: 'host-error', detail: 'ECONNRESET', + })); + // The second host was never touched by the first host's failure. + expect(report.hosts[1].status).toBe('ok'); + expect(fs.existsSync(path.join(home, '.cursor', 'skills', 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + }); + + it('reports no commit when nothing was cloned this run (native-only)', async()=>{ + const report = await run_skills(opts({agents: ['claude-code']})); + expect(report.source).toEqual({repo: 'reply-team/reply-skills', ref: 'main'}); + }); + + // I3 (final review): `update` was never exercised through run_skills at + // all, which is exactly where the disagreement showed — one native host + // and one flat host answering "already at the target version" two + // different ways in a single report. + it('reports current from both adapters when update finds every pack already at the target version', async()=>{ + // The host reports all three packs installed at the registry version, + // so neither adapter has anything to move. + const installed_run: Runner = async(bin, args)=>{ + calls.push([bin, ...args]); + if (args.includes('list')) + { + return { + code: 0, + stdout: JSON.stringify({plugins: ['ai-sdr-core', 'reply-adapter', 'agentic-runtime'] + .map(name=>({name, marketplace: 'reply-skills', version: '0.1.0'}))}), + stderr: '', + }; + } + return {code: 0, stdout: '', stderr: ''}; + }; + const with_run = (): Record=>({...opts().deps, run: installed_run}); + + await run_skills(opts({deps: with_run()})); + const report = await run_skills(opts({operation: 'update', deps: with_run()})); + + expect(report.hosts.map(h=>h.host)).toEqual(['claude-code', 'cursor']); + for (const host of report.hosts) + { + expect(host.packs?.map(p=>[p.name, p.action])).toEqual([ + ['ai-sdr-core', 'current'], + ['reply-adapter', 'current'], + ['agentic-runtime', 'current'], + ]); + } + // Nothing changed, so the "start a new session" advice must not fire. + expect(human_lines(report).join('\n')).not.toMatch(/new session/i); + }); + + // The flat adapter clones a ref, so `update` can rewrite every file while + // the version stays 0.1.0. That still reports `current` — the version did + // not move — but the user must reload to pick the new files up. + it('still advises a new session when update rewrote files at an unchanged version', async()=>{ + const clone_at = (commit: string)=>async()=>{ + const {dir} = await fake_clone(); + return {dir, commit}; + }; + await run_skills(opts({agents: ['cursor'], deps: {...opts().deps, clone: clone_at('aaaaaaa')}})); + const report = await run_skills(opts({ + operation: 'update', agents: ['cursor'], + deps: {...opts().deps, clone: clone_at('bbbbbbb')}, + })); + + expect(report.hosts[0].packs?.map(p=>p.action)).toEqual(['current', 'current', 'current']); + expect(human_lines(report).join('\n')).toMatch(/new session/i); + }); + + // I5 (final review): four hosts ship with paths taken from documentation + // rather than a verification run, and nothing surfaced it. + it('carries each host\'s verified flag into the report', async()=>{ + const report = await run_skills(opts()); + expect(report.hosts.map(h=>[h.host, h.verified])).toEqual([ + ['claude-code', true], + ['cursor', false], + ]); + expect(human_lines(report).join('\n')).toContain('paths not yet verified'); + }); + + it('carries the verified flag on a host that was requested but not installed', async()=>{ + const report = await run_skills(opts({agents: ['windsurf']})); + expect(report.hosts[0].verified).toBe(false); + }); + + it('reports no commit when the clone failed', async()=>{ + const failing_clone = async()=>{ + throw new Error('git not found'); + }; + const report = await run_skills(opts({ + agents: ['cursor'], + deps: {...opts().deps, clone: failing_clone}, + })); + expect(report.hosts[0]).toEqual(expect.objectContaining({status: 'failed', reason: 'clone-failed'})); + expect(report.source).toEqual({repo: 'reply-team/reply-skills', ref: 'main'}); + }); +}); diff --git a/src/__tests__/skills/packs.test.ts b/src/__tests__/skills/packs.test.ts new file mode 100644 index 0000000..69887cd --- /dev/null +++ b/src/__tests__/skills/packs.test.ts @@ -0,0 +1,120 @@ +import {describe, it, expect, vi} from 'vitest'; +import {parse_packs, load_packs, resolve_packs, packs_url, PACKS_FALLBACK, DEFAULT_REF} from '../../skills/packs'; +import {UsageError, RuntimeError} from '../../utils/errors'; + +const registry = PACKS_FALLBACK; +const names = (packs: {name: string}[]): string[]=>packs.map(p=>p.name); + +describe('resolve_packs', ()=>{ + it('returns every pack, core first, when nothing is requested', ()=>{ + expect(names(resolve_packs([], registry))).toEqual(['ai-sdr-core', 'reply-adapter', 'agentic-runtime']); + }); + + it('expands the short aliases', ()=>{ + expect(names(resolve_packs(['core'], registry))).toEqual(['ai-sdr-core']); + expect(names(resolve_packs(['runtime'], registry))).toEqual(['ai-sdr-core', 'agentic-runtime']); + }); + + it('accepts canonical names too', ()=>{ + expect(names(resolve_packs(['ai-sdr-core'], registry))).toEqual(['ai-sdr-core']); + }); + + it('pulls the dependency and keeps it first', ()=>{ + expect(names(resolve_packs(['adapter'], registry))).toEqual(['ai-sdr-core', 'reply-adapter']); + }); + + it('de-duplicates a pack requested twice or pulled twice', ()=>{ + expect(names(resolve_packs(['adapter', 'runtime', 'core'], registry))) + .toEqual(['ai-sdr-core', 'reply-adapter', 'agentic-runtime']); + }); + + it('rejects an unknown name with a usage error listing the valid ones', ()=>{ + expect(()=>resolve_packs(['nope'], registry)).toThrow(UsageError); + try { resolve_packs(['nope'], registry); } + catch (e) { expect((e as UsageError).hint).toContain('ai-sdr-core'); } + }); + + // Removal must not expand the graph: `remove runtime` means that pack and + // nothing else. Expanding would drag the core out from under the adapter. + it('does not pull dependencies when dependencies are switched off', ()=>{ + expect(names(resolve_packs(['runtime'], registry, {dependencies: false}))).toEqual(['agentic-runtime']); + expect(names(resolve_packs(['adapter'], registry, {dependencies: false}))).toEqual(['reply-adapter']); + }); + + it('still returns everything, dependency-ordered, when nothing is requested', ()=>{ + expect(names(resolve_packs([], registry, {dependencies: false}))) + .toEqual(['ai-sdr-core', 'reply-adapter', 'agentic-runtime']); + }); + + it('keeps registry order when dependencies are off, so reversing gives dependents first', ()=>{ + expect(names(resolve_packs(['runtime', 'core'], registry, {dependencies: false}))) + .toEqual(['ai-sdr-core', 'agentic-runtime']); + }); +}); + +describe('parse_packs', ()=>{ + it('reads marketplace name and packs', ()=>{ + const parsed = parse_packs({ + marketplace: {name: 'reply-skills'}, + packs: [{name: 'a', displayName: 'A', version: '1.0.0', description: 'd', dependencies: []}], + }); + expect(parsed.marketplace).toBe('reply-skills'); + expect(parsed.packs[0]).toEqual({name: 'a', display_name: 'A', version: '1.0.0', description: 'd', dependencies: []}); + }); + + it('defaults a missing dependencies array to empty', ()=>{ + const parsed = parse_packs({ + marketplace: {name: 'm'}, + packs: [{name: 'a', version: '1.0.0'}], + }); + expect(parsed.packs[0].dependencies).toEqual([]); + }); + + it('rejects the whole document when a pack has no name', ()=>{ + expect(()=>parse_packs({marketplace: {name: 'm'}, packs: [{version: '1.0.0'}]})).toThrow(RuntimeError); + }); + + it('rejects a document with no packs array', ()=>{ + expect(()=>parse_packs({marketplace: {name: 'm'}})).toThrow(RuntimeError); + }); + + it('rejects a dependency that names a pack not in the document', ()=>{ + expect(()=>parse_packs({ + marketplace: {name: 'm'}, + packs: [{name: 'a', version: '1.0.0', dependencies: ['ghost']}], + })).toThrow(RuntimeError); + }); +}); + +describe('load_packs', ()=>{ + it('builds the raw URL from the ref', ()=>{ + expect(packs_url('v1.2.3')).toBe( + 'https://raw.githubusercontent.com/reply-team/reply-skills/v1.2.3/packs.json'); + expect(packs_url(DEFAULT_REF)).toContain('/main/packs.json'); + }); + + it('uses the fetched document when the request succeeds', async()=>{ + const body = {marketplace: {name: 'reply-skills'}, packs: [{name: 'solo', version: '9.9.9'}]}; + const fetch_impl = vi.fn().mockResolvedValue(new Response(JSON.stringify(body), {status: 200})); + const parsed = await load_packs({fetch_impl: fetch_impl as unknown as typeof fetch}); + expect(names(parsed.packs)).toEqual(['solo']); + }); + + it('falls back to the embedded copy when the network fails', async()=>{ + const fetch_impl = vi.fn().mockRejectedValue(new TypeError('offline')); + const parsed = await load_packs({fetch_impl: fetch_impl as unknown as typeof fetch}); + expect(names(parsed.packs)).toEqual(names(PACKS_FALLBACK.packs)); + }); + + it('falls back on a non-200 response', async()=>{ + const fetch_impl = vi.fn().mockResolvedValue(new Response('nope', {status: 404})); + const parsed = await load_packs({fetch_impl: fetch_impl as unknown as typeof fetch}); + expect(names(parsed.packs)).toEqual(names(PACKS_FALLBACK.packs)); + }); + + it('falls back when the fetched document is malformed rather than throwing', async()=>{ + const fetch_impl = vi.fn().mockResolvedValue(new Response('{"packs":[{"no":"name"}]}', {status: 200})); + const parsed = await load_packs({fetch_impl: fetch_impl as unknown as typeof fetch}); + expect(names(parsed.packs)).toEqual(names(PACKS_FALLBACK.packs)); + }); +}); diff --git a/src/__tests__/skills/report.test.ts b/src/__tests__/skills/report.test.ts new file mode 100644 index 0000000..d1280b1 --- /dev/null +++ b/src/__tests__/skills/report.test.ts @@ -0,0 +1,195 @@ +import {describe, it, expect} from 'vitest'; +import {human_lines, exit_code_for, summarize, dependency_note} from '../../skills/report'; +import type {Host_outcome, Report} from '../../skills/types'; + +const strip = (s: string): string=>s.replace(/\x1b\[[0-9;]*m/g, ''); +const text = (report: Report): string=>human_lines(report).map(strip).join('\n'); + +const host = (over: Partial = {}): Host_outcome=>({ + host: 'claude-code', label: 'Claude Code', kind: 'native-plugin', scope: 'user', status: 'ok', + packs: [ + {name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}, + {name: 'reply-adapter', action: 'installed', version: '0.1.0'}, + ], + ...over, +}); + +const report = (hosts: Host_outcome[], over: Partial = {}): Report=>{ + const action = over.action ?? 'install'; + return { + action, + source: {repo: 'reply-team/reply-skills', ref: 'main'}, + requested: ['ai-sdr-core', 'reply-adapter'], + resolved: ['ai-sdr-core', 'reply-adapter'], + hosts, + summary: summarize(hosts, action), + ...over, + }; +}; + +describe('summarize', ()=>{ + it('counts hosts by outcome, not packs', ()=>{ + expect(summarize([host(), host({host: 'codex', status: 'skipped'}), host({host: 'x', status: 'failed'})], 'install')) + .toEqual({installed: 1, skipped: 1, failed: 1}); + }); + + it('counts a partial host as installed, because something landed', ()=>{ + expect(summarize([host({status: 'partial'})], 'install')).toEqual({installed: 1, skipped: 0, failed: 0}); + }); + + it('for list, installed means a pack is actually present, not merely that the host answered', ()=>{ + const empty_hosts = [host({packs: []}), host({host: 'codex', packs: []}), host({host: 'x', packs: []})]; + expect(summarize(empty_hosts, 'list')).toEqual({installed: 0, skipped: 0, failed: 0}); + }); + + it('for list, counts only the host that actually has a pack present', ()=>{ + const hosts = [ + host({packs: [{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]}), + host({host: 'codex', packs: []}), + host({host: 'x', packs: []}), + ]; + expect(summarize(hosts, 'list')).toEqual({installed: 1, skipped: 0, failed: 0}); + }); + + it('for install, the same empty-packs host shapes still count by status, unchanged', ()=>{ + const empty_hosts = [host({packs: []}), host({host: 'codex', packs: []}), host({host: 'x', packs: []})]; + expect(summarize(empty_hosts, 'install')).toEqual({installed: 3, skipped: 0, failed: 0}); + }); +}); + +describe('human_lines', ()=>{ + it('names the detected hosts and no count', ()=>{ + const out = text(report([host(), host({host: 'codex', label: 'Codex'})])); + expect(out).toContain('detected Claude Code, Codex'); + expect(out).not.toMatch(/supported/i); + }); + + it('omits the detected line when nothing was found', ()=>{ + const out = text(report([])); + expect(out).not.toContain('detected'); + expect(out).toMatch(/no supported assistant/i); + }); + + it('lists the packs per host', ()=>{ + expect(text(report([host()]))).toContain('Claude Code · ai-sdr-core, reply-adapter installed'); + }); + + it('says current rather than installed when nothing changed', ()=>{ + const out = text(report([host({packs: [{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]})])); + expect(out).toContain('already current'); + }); + + it('shows the reason and hint for a skipped host', ()=>{ + const out = text(report([host({ + status: 'skipped', packs: undefined, + reason: 'cli-not-resolved', detail: 'no binary', hint: 'add codex to PATH', + })])); + expect(out).toContain('skipped — no binary'); + expect(out).toContain('add codex to PATH'); + }); + + it('surfaces a pack-level detail when the pack action is failed', ()=>{ + const lines = human_lines(report([host({ + packs: [ + {name: 'ai-sdr-core', action: 'installed', version: '0.1.0'}, + {name: 'reply-adapter', action: 'failed', version: '0.1.0', detail: 'installation incomplete; run `reply skills install` to repair'}, + ], + })])).map(strip); + expect(lines).toContain(' reply-adapter: installation incomplete; run `reply skills install` to repair'); + expect(lines.filter(l=>l.startsWith(' ') && l.includes('installation incomplete')).length).toBe(1); + }); + + it('distinguishes multiple failed packs with their own detail lines', ()=>{ + const lines = human_lines(report([host({ + packs: [ + {name: 'ai-sdr-core', action: 'failed', version: '0.1.0', detail: 'network timeout'}, + {name: 'reply-adapter', action: 'failed', version: '0.1.0', detail: 'disk full'}, + ], + })])).map(strip); + expect(lines).toContain(' ai-sdr-core: network timeout'); + expect(lines).toContain(' reply-adapter: disk full'); + expect(lines.filter(l=>l.match(/^ (ai-sdr-core|reply-adapter):/)).length).toBe(2); + }); + + // I5: a green tick on a host whose skills directory we have never + // confirmed the assistant reads from claims more than we know. + it('marks a host whose paths are not yet verified, and only that host', ()=>{ + const out = text(report([ + host({verified: true}), + host({host: 'cursor', label: 'Cursor', verified: false}), + ])); + expect(out).toMatch(/Cursor .*paths not yet verified/); + expect(out.split('\n').filter(l=>l.includes('paths not yet verified'))).toHaveLength(1); + }); + + it('says nothing about verification for a host that reported no packs', ()=>{ + const out = text(report([host({ + host: 'cursor', label: 'Cursor', verified: false, status: 'skipped', packs: undefined, + reason: 'not-detected', detail: 'Cursor is not installed on this machine', + })])); + expect(out).not.toContain('paths not yet verified'); + }); + + it('reports a pulled dependency once', ()=>{ + expect(dependency_note(['reply-adapter'], ['ai-sdr-core', 'reply-adapter'])) + .toBe('ai-sdr-core added — required by reply-adapter'); + expect(dependency_note(['ai-sdr-core'], ['ai-sdr-core'])).toBeUndefined(); + }); + + it('tells the user a new session is needed after a successful install', ()=>{ + expect(text(report([host()]))).toMatch(/new session/i); + }); + + it('does not ask for a new session when nothing changed', ()=>{ + const out = text(report([host({packs: [{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]})])); + expect(out).not.toMatch(/new session/i); + }); + + // An unchanged version reports `current` in every adapter, which is right — + // but a flat host that re-copied a newer commit at that same version did + // rewrite the files, and the user has to reload to pick them up. + it('asks for a new session when files were rewritten at an unchanged version', ()=>{ + const out = text(report([host({ + packs: [{name: 'ai-sdr-core', action: 'current', version: '0.1.0', refreshed: true}], + })])); + expect(out).toContain('already current'); + expect(out).toMatch(/new session/i); + }); + + it('marks an outdated pack as an available update on list, not as updated', ()=>{ + const out = text(report([host({ + packs: [{name: 'ai-sdr-core', action: 'upgraded', version: '0.2.0', from: '0.1.0'}], + })], {action: 'list'})); + expect(out).toContain('update available'); + expect(out).not.toContain('updated'); + }); + + it('still says updated for an upgraded pack on install', ()=>{ + const out = text(report([host({ + packs: [{name: 'ai-sdr-core', action: 'upgraded', version: '0.2.0', from: '0.1.0'}], + })], {action: 'install'})); + expect(out).toContain('ai-sdr-core updated'); + }); +}); + +describe('exit_code_for', ()=>{ + it('is 0 when at least one host succeeded', ()=>{ + expect(exit_code_for(report([host(), host({host: 'codex', status: 'failed'})]))).toBe(0); + }); + + it('is 0 for a partial host', ()=>{ + expect(exit_code_for(report([host({status: 'partial'})]))).toBe(0); + }); + + it('is 1 when nothing installed anywhere', ()=>{ + expect(exit_code_for(report([host({status: 'failed'}), host({host: 'codex', status: 'skipped'})]))).toBe(1); + }); + + it('is 1 when no host was found at all', ()=>{ + expect(exit_code_for(report([]))).toBe(1); + }); + + it('is 0 for a list run that found nothing to report', ()=>{ + expect(exit_code_for(report([], {action: 'list'}))).toBe(0); + }); +}); diff --git a/src/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..c3e4cc5 --- /dev/null +++ b/src/commands/skills.ts @@ -0,0 +1,164 @@ +import {Command} from 'commander'; +import {PROGRAM_NAME} from '../config'; +import {run_skills} from '../skills/orchestrate'; +import {exit_code_for, human_lines} from '../skills/report'; +import {RuntimeError} from '../utils/errors'; +import {print, type Print_opts} from '../utils/output'; +import type {Operation} from '../skills/types'; + +type Skills_cli_opts = { + agent?: string[]; + project?: boolean; + dryRun?: boolean; + json?: boolean; + pretty?: boolean; +}; + +const read_globals = (cmd: Command): Skills_cli_opts=>{ + const o = cmd.optsWithGlobals(); + return {agent: o.agent, project: o.project, dryRun: o.dryRun, json: o.json, pretty: o.pretty}; +}; + +const wants_json = (o: Skills_cli_opts): boolean=>Boolean(o.json || o.pretty); +const print_opts = (o: Skills_cli_opts): Print_opts=>({json: o.json, pretty: o.pretty}); + +// What exiting 1 means, per operation. `exit_code_for` already branches on the +// action; telling a failed `remove` that "no assistant received the skills" +// and pointing it at `install --dry-run` misdirects the one user who most +// needs the right command. `list` never exits 1 (it is a query), but the map +// stays total so a new operation cannot silently inherit the wrong wording. +const NOTHING_HAPPENED: Record = { + install: { + title: 'No assistant received the skills.', + code: 'skills.nothing_installed', + probe: 'install --dry-run', + }, + update: { + title: 'No assistant was updated.', + code: 'skills.nothing_updated', + probe: 'update --dry-run', + }, + remove: { + title: 'No assistant had the skills removed.', + code: 'skills.nothing_removed', + probe: 'remove --dry-run', + }, + list: { + title: 'No assistant could be queried.', + code: 'skills.nothing_listed', + probe: 'list --json', + }, +}; + +// One handler for all four operations: they differ only in the operation name, +// so the reporting and exit-code contract stays in exactly one place. +const handle_skills = async( + operation: Operation, + packs: string[], + opts: Skills_cli_opts, +): Promise=>{ + const report = await run_skills({ + operation, + requested: packs, + agents: opts.agent, + project: opts.project === true, + dry_run: opts.dryRun === true, + }); + + if (wants_json(opts)) + { + print(report, print_opts(opts)); + } + else + { + // `list` is a data command, so its table is what the user redirects — + // it goes to stdout. Every other operation prints progress, which is + // status and belongs on stderr (see utils/output.ts). + const write = report.action === 'list' ? console.log : console.error; + for (const line of human_lines(report)) + { + write(line); + } + } + + // The report is printed either way: exiting non-zero without it would hide + // why each host failed. + if (exit_code_for(report) !== 0) + { + const failure = NOTHING_HAPPENED[operation]; + throw new RuntimeError(failure.title, { + code: failure.code, + hint: `run \`${PROGRAM_NAME} skills ${failure.probe}\` to see what was attempted`, + }); + } +}; + +const skills_command = new Command('skills') + .description('Install and manage Reply skill packs in your AI assistants'); + +const with_flags = (cmd: Command): Command=>cmd + .option('-a, --agent ', 'Target these assistants instead of auto-detecting') + .option('--project', 'Install into the current repository instead of your user directory') + .option('--dry-run', 'Show what would happen and change nothing'); + +const packs_help = ` +Packs: ai-sdr-core (alias core) · reply-adapter (adapter) · agentic-runtime (runtime). +No pack means all three; dependencies are resolved for you.`; + +with_flags(skills_command + .command('install') + .argument('[packs...]', 'Packs to install (default: all three)') + .description('Install Reply skill packs into every detected assistant')) + .addHelpText('after', `${packs_help} + +Examples: + ${PROGRAM_NAME} skills install + ${PROGRAM_NAME} skills install core + ${PROGRAM_NAME} skills install adapter runtime + ${PROGRAM_NAME} skills install --agent codex --json`) + .action(async function(this: Command, packs: string[]) { + await handle_skills('install', packs, read_globals(this)); + }); + +with_flags(skills_command + .command('list') + .argument('[packs...]', 'Limit the listing to these packs') + .description('Show which packs are installed in which assistant')) + .addHelpText('after', `${packs_help} + +Examples: + ${PROGRAM_NAME} skills list + ${PROGRAM_NAME} skills list --json`) + .action(async function(this: Command, packs: string[]) { + await handle_skills('list', packs, read_globals(this)); + }); + +with_flags(skills_command + .command('update') + .argument('[packs...]', 'Packs to update (default: all installed)') + .description('Update installed packs to the latest published version')) + .addHelpText('after', `${packs_help} + +Examples: + ${PROGRAM_NAME} skills update + ${PROGRAM_NAME} skills update --dry-run`) + .action(async function(this: Command, packs: string[]) { + await handle_skills('update', packs, read_globals(this)); + }); + +with_flags(skills_command + .command('remove') + .argument('[packs...]', 'Packs to remove (default: all of them)') + .description('Remove Reply skill packs from your assistants')) + .addHelpText('after', `${packs_help} +Removing a pack that another installed pack depends on is refused — remove both, +or run \`${PROGRAM_NAME} skills remove\` with no pack to remove everything. + +Examples: + ${PROGRAM_NAME} skills remove + ${PROGRAM_NAME} skills remove runtime`) + .action(async function(this: Command, packs: string[]) { + await handle_skills('remove', packs, read_globals(this)); + }); + +export {skills_command, handle_skills}; diff --git a/src/config.ts b/src/config.ts index aa93b12..891355e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -86,9 +86,13 @@ const credentials_file = (env: Env = process.env): string=> const config_file = (env: Env = process.env): string=> path.join(config_dir(env), 'config.json'); +// What the installer wrote into flat-directory hosts (see skills/journal.ts). +const skills_file = (env: Env = process.env): string=> + path.join(config_dir(env), 'skills.json'); + export { PROGRAM_NAME, APP_NAME, env_prefix, env_var, get_env, cli_version, user_agent, - default_config_dir, config_dir, credentials_file, config_file, + default_config_dir, config_dir, credentials_file, config_file, skills_file, }; export type {Env}; diff --git a/src/index.ts b/src/index.ts index ff67449..907c27c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import {auth_command} from './commands/auth'; import {profile_command} from './commands/profile'; import {team_command} from './commands/team'; import {api_command} from './commands/api'; +import {skills_command} from './commands/skills'; import {CliError} from './utils/errors'; import {set_quiet} from './utils/output'; @@ -44,6 +45,7 @@ const build_program = (): Command=>{ program.addCommand(profile_command); program.addCommand(team_command); program.addCommand(api_command); + program.addCommand(skills_command); program.addHelpText('after', ` Credential precedence: @@ -94,6 +96,8 @@ Examples: ${PROGRAM_NAME} auth status ${PROGRAM_NAME} team list ${PROGRAM_NAME} api /v3/sequences + ${PROGRAM_NAME} skills install + ${PROGRAM_NAME} skills list --json `); return program; diff --git a/src/skills/adapter-flat.ts b/src/skills/adapter-flat.ts new file mode 100644 index 0000000..18152b3 --- /dev/null +++ b/src/skills/adapter-flat.ts @@ -0,0 +1,582 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {default_runner} from './adapter-native'; +import {forget_pack, journal_entry, read_journal, record_pack} from './journal'; +import {DEFAULT_REF, REPO} from './packs'; +import type {Env} from '../config'; +import type {Detected_host} from './detect'; +import type {Journal_entry} from './journal'; +import type {Host_def, Host_outcome, Operation, Pack, Pack_outcome, Runner, Scope} from './types'; + +// For hosts with no plugin mechanism: a pack is just a directory of skills, so +// installing is a copy. `git` is required here; both native hosts already need +// it for `marketplace add`, and the resolved commit lands in the journal. + +type Clone_result = {dir: string; commit: string}; +type Clone_fn = (opts: {ref: string; run: Runner; tmp_root: string})=>Promise; + +const clone_repo: Clone_fn = async({ref, run, tmp_root})=>{ + const dir = fs.mkdtempSync(path.join(tmp_root, 'reply-skills-')); + const url = `https://github.com/${REPO}.git`; + const cloned = await run('git', ['clone', '--depth', '1', '--branch', ref, url, dir]); + if (cloned.code !== 0) + { + fs.rmSync(dir, {recursive: true, force: true}); + throw new Error((cloned.stderr || cloned.stdout).trim() || `git clone failed for ${url}`); + } + const head = await run('git', ['-C', dir, 'rev-parse', 'HEAD']); + if (head.code !== 0) + { + fs.rmSync(dir, {recursive: true, force: true}); + throw new Error((head.stderr || head.stdout).trim() || `git rev-parse HEAD failed for ${url}`); + } + return {dir, commit: head.stdout.trim().slice(0, 7)}; +}; + +// Where this host reads skills from, for the requested scope. A native host +// only lands here under --project, because its plugin mechanism is user-scoped. +// Returns undefined when the host has no directory configured for this scope +// (e.g. a native host under `user` scope) so the caller can report a status +// instead of joining `undefined` into a path. +const skills_target = (def: Host_def, scope: Scope, home: string, cwd: string): string | undefined=>{ + const rel = scope === 'project' ? def.project_skills_dir : def.user_skills_dir; + return rel === undefined ? undefined : path.join(scope === 'project' ? cwd : home, rel); +}; + +// Mutates `written` as it goes, rather than returning a fresh array, so that +// a throw partway through (a read-only destination, a full disk) still leaves +// the caller with exactly the files that landed before the failure — see the +// install loop, which journals that partial list instead of orphaning it. +const copy_dir = (from: string, to: string, written: string[]): void=>{ + fs.mkdirSync(to, {recursive: true}); + for (const entry of fs.readdirSync(from, {withFileTypes: true})) + { + const src = path.join(from, entry.name); + const dst = path.join(to, entry.name); + if (entry.isDirectory()) + { + copy_dir(src, dst, written); + continue; + } + fs.copyFileSync(src, dst); + written.push(dst); + } +}; + +// True when `target` resolves inside `root` — never equal to it, never above +// it via `..`. Every path handed to delete_files must pass this: the journal +// is JSON in the user's config directory, and a hand-edited or stale entry +// must not be able to name a file outside the host's own skills directory. +// `path.relative` — not string equality — so this agrees with the OS on +// whether two differently-cased paths are the same file, which matters on +// Windows: is_within, owns_dir and the protected-files check must all reach +// the same answer for the same pair of paths. +const is_within = (root: string, target: string): boolean=>{ + const rel = path.relative(root, target); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +}; + +const paths_equal = (a: string, b: string): boolean=> + path.relative(a, b) === ''; + +// What delete_files could not do. `outside` are paths the containment check +// refused — a tampered or stale journal entry naming somewhere else; `failed` +// are files the OS would not delete. Both are returned rather than swallowed: +// a caller that reports `removed` for a file still sitting on disk is lying, +// which is the only way this adapter's `remove` could ever mislead. +type Delete_failure = {file: string; message: string}; +type Delete_result = {outside: string[]; failed: Delete_failure[]}; + +// Removes the files we're told to, minus two carve-outs: anything outside +// `target_root` (a tampered or stale journal entry) and anything another +// host's own journal entry still claims (several flat hosts share the same +// project-scope directory, see claimed_by_others). What's left is pruned back +// to empty directories, but `target_root` itself is never removed. +const delete_files = (files: string[], target_root: string, protected_files: Set = new Set()): Delete_result=>{ + const resolved_root = path.resolve(target_root); + const dirs = new Set(); + const outside: string[] = []; + const failed: Delete_failure[] = []; + for (const file of files) + { + const resolved = path.resolve(file); + if (!is_within(resolved_root, resolved)) + { + outside.push(resolved); + continue; + } + // A sibling host still claims this one; not deleting it is the point, + // so it is neither a failure nor a refusal. + if ([...protected_files].some(p=>paths_equal(p, resolved))) + { + continue; + } + try { + // `force` already makes a missing file a no-op, so removal stays + // idempotent without a catch. Anything that does throw here is a + // real failure — a read-only file on Windows (EPERM), an open + // handle (EBUSY) — and must be reported, not swallowed. + fs.rmSync(resolved, {force: true}); + } catch (error) { + failed.push({file: resolved, message: (error as Error).message}); + continue; + } + dirs.add(path.dirname(resolved)); + } + for (const dir of [...dirs].sort((a, b)=>b.length - a.length)) + { + if (dir === resolved_root) + { + continue; + } + try { + if (!fs.readdirSync(dir).length) + { + fs.rmdirSync(dir); + } + } catch { + // Non-empty or missing — leave it alone. + } + } + return {outside, failed}; +}; + +// The user-facing reason a removal did not fully happen, or undefined when it +// did. Anything but undefined means the pack must be reported `failed` and its +// journal entry kept, so the next run can retry. +const delete_detail = (result: Delete_result, target_root: string): string | undefined=>{ + const reasons: string[] = []; + if (result.outside.length) + { + reasons.push(`${result.outside.length} recorded file(s) sit outside ${target_root} and were left alone` + + `, starting with ${result.outside[0]}`); + } + for (const failure of result.failed) + { + reasons.push(`could not delete ${failure.file}: ${failure.message}`); + } + return reasons.length ? reasons.join('; ') : undefined; +}; + +// Absolute file paths that some *other* host's journal entry for this exact +// (scope, pack) still claims. Several flat hosts resolve the same physical +// project-scope directory (`.agents/skills`), so deleting or overwriting one +// host's copy must not break a sibling host's install of the same pack. +// `project_root` narrows this to the repository this run is acting on: a +// sibling's entry recorded in a different checkout claims nothing here. +const claimed_by_others = ( + env: Env | undefined, + scope: Scope, + pack_name: string, + exclude_host: string, + project_root?: string, +): Set=>{ + const journal = read_journal(env); + const claimed = new Set(); + for (const [host, scopes] of Object.entries(journal.hosts)) + { + if (host === exclude_host) + { + continue; + } + const entry = scopes[scope]?.[pack_name]; + if (!entry) + { + continue; + } + if (project_root !== undefined && entry.project_root !== undefined + && !paths_equal(entry.project_root, project_root)) + { + continue; + } + for (const file of entry.files) + { + claimed.add(path.resolve(file)); + } + } + return claimed; +}; + +// True when every file under `dir` is accounted for by files we already know +// about — our own previous install of this pack, or a sibling host's install +// of the same pack at a shared directory. Anything else sitting at `dir` is +// foreign (typically user-authored) and must not be clobbered. Reuses +// is_within rather than a raw prefix check, so this agrees with delete_files' +// containment check on a differently-cased path (routine on Windows). +const owns_dir = (dir: string, known_files: Iterable): boolean=>{ + for (const file of known_files) + { + if (is_within(dir, file)) + { + return true; + } + } + return false; +}; + +// Mirrors adapter-native.ts's status_of: 'ok' with no failures, 'failed' when +// every pack in this operation failed, 'partial' otherwise. Kept local rather +// than shared, since the two adapters are twin, independent implementations +// of the same rule. +const status_of = (packs: Pack_outcome[]): Host_outcome['status']=>{ + const failed = packs.filter(p=>p.action === 'failed'); + if (!failed.length) + { + return 'ok'; + } + return failed.length === packs.length ? 'failed' : 'partial'; +}; + +// Twins of adapter-native.ts's pair, for the same reason status_of is a twin: +// install refuses a pack whose dependency failed, remove refuses a pack whose +// dependent failed, and the user reads the identical sentence either way. +const blocked_hint = (names: Iterable): string | undefined=>{ + const list = [...names]; + return list.length + ? `packs ${list.join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` + : undefined; +}; + +const kept_hint = (names: Iterable): string | undefined=>{ + const list = [...names]; + return list.length + ? `packs ${list.join(', ')} were kept because packs that depend on them could not be removed; fix those removals and re-run` + : undefined; +}; + +// A copy that landed at the version the pack was already on is not an upgrade, +// even though this adapter really did re-copy from a fresh clone — the commit +// carries that difference, the version does not. Mirrors +// adapter-native.ts's updated_outcome so `current` means one thing everywhere. +// +// But "the version moved" and "the bytes moved" are two different facts, and +// this adapter clones a *ref*: a new commit on `main` can rewrite every file +// at an unchanged 0.1.0, and so can repairing an install that never finished. +// `current` cannot say that, so `refreshed` does — otherwise a run that really +// did rewrite the user's files reads as a no-op and the reporter never tells +// them to start a new session. +// +// `commit` is the commit these files were copied from, and is passed only by +// the path that actually copied: a dry run never clones, so it cannot know +// whether the ref moved and must not guess. +const copied_outcome = ( + pack_name: string, + version: string, + previous: Journal_entry | undefined, + commit?: string, +): Pack_outcome=>{ + if (previous === undefined) + { + return {name: pack_name, action: 'installed', version}; + } + if (previous.version !== version) + { + return {name: pack_name, action: 'upgraded', version, from: previous.version}; + } + const refreshed = commit !== undefined + && (previous.commit !== commit || !previous.complete); + return refreshed + ? {name: pack_name, action: 'current', version, refreshed: true} + : {name: pack_name, action: 'current', version}; +}; + +type Flat_opts = { + operation: Operation; + host: Detected_host; + packs: Pack[]; + scope: Scope; + ref?: string; + run?: Runner; + home?: string; + cwd?: string; + tmp_root?: string; + env?: Env; + dry_run?: boolean; + clone?: Clone_fn; +}; + +const run_flat = async(opts: Flat_opts): Promise=>{ + const {operation, host, packs, scope} = opts; + const run = opts.run ?? default_runner; + const ref = opts.ref ?? DEFAULT_REF; + const home = opts.home ?? os.homedir(); + const cwd = opts.cwd ?? process.cwd(); + const tmp_root = opts.tmp_root ?? os.tmpdir(); + const dry_run = opts.dry_run === true; + const clone = opts.clone ?? clone_repo; + const id = host.def.id; + const base: Host_outcome = { + host: id, label: host.def.label, kind: 'flat-skills-dir', scope, status: 'ok', + }; + // The repository a project-scope run acts on, and the identity a + // project-scope journal entry is stamped with. Undefined under user scope, + // whose directory is the home directory and cannot be confused with + // another one. + const project_root = scope === 'project' ? path.resolve(cwd) : undefined; + // An entry written from a different checkout is not this run's to read, + // replace or forget: `remove --project` from the wrong repository must + // report nothing, rather than delete nothing (containment refuses every + // path) and still claim `removed`. Entries written before the field + // existed carry no root and are treated as ours, so nothing already + // journaled becomes unreachable. + const belongs_here = (entry: Journal_entry): boolean=> + project_root === undefined + || entry.project_root === undefined + || paths_equal(entry.project_root, project_root); + // Scope-bound wrappers: every journal lookup for this run goes through + // these, so neither `scope` nor the project root can be forgotten at a + // call site. + const entry_for = (pack_name: string): Journal_entry | undefined=>{ + const entry = journal_entry(id, scope, pack_name, opts.env); + return entry && belongs_here(entry) ? entry : undefined; + }; + const record_for = (pack_name: string, data: Journal_entry): void=> + record_pack(id, scope, pack_name, project_root ? {...data, project_root} : data, opts.env); + const forget_for = (pack_name: string): Journal_entry | undefined=> + forget_pack(id, scope, pack_name, opts.env); + const others_claim = (pack_name: string): Set=> + claimed_by_others(opts.env, scope, pack_name, id, project_root); + const outcomes: Pack_outcome[] = []; + + if (operation === 'list') + { + for (const pack of packs) + { + const entry = entry_for(pack.name); + if (!entry) + { + continue; + } + // An incomplete entry never reads as current, regardless of + // version — it is a copy that did not finish, and needs a repair + // install, not a clean bill of health. + if (!entry.complete) + { + outcomes.push({ + name: pack.name, + action: 'failed', + detail: 'installation incomplete; run `reply skills install` to repair', + }); + continue; + } + outcomes.push(entry.version === pack.version + ? {name: pack.name, action: 'current', version: entry.version} + : {name: pack.name, action: 'upgraded', version: pack.version, from: entry.version}); + } + return {...base, packs: outcomes, status: status_of(outcomes)}; + } + + // Every other operation touches the filesystem, so a host with no + // directory configured for this scope (a native host under `user` scope) + // is reported, not crashed on. + const target_root = skills_target(host.def, scope, home, cwd); + if (!target_root) + { + return { + ...base, + status: 'skipped', + reason: 'no-skills-dir', + detail: `${host.def.label} has no ${scope} skills directory`, + }; + } + + if (operation === 'remove') + { + // Reverse dependency order, and — since delete_files can now report a + // file it could not remove — the same transposed guard as + // adapter-native.ts: a dependency is never dropped once a pack that + // depends on it failed to be removed, so no host is left holding an + // adapter with no core. Reverse order means every dependent has + // already been visited, so the block propagates down the chain. + const failed_names = new Set(); + const blocked_names = new Set(); + const kept_names = new Set(); + for (const pack of [...packs].reverse()) + { + const blocker = packs.find(p=>p.dependencies.includes(pack.name) + && (failed_names.has(p.name) || blocked_names.has(p.name))); + if (blocker) + { + blocked_names.add(pack.name); + if (entry_for(pack.name)) + { + kept_names.add(pack.name); + } + continue; + } + const entry = entry_for(pack.name); + if (!entry) + { + continue; + } + if (dry_run) + { + outcomes.push({name: pack.name, action: 'removed', version: entry.version}); + continue; + } + const refusal = delete_detail( + delete_files(entry.files, target_root, others_claim(pack.name)), + target_root, + ); + if (refusal) + { + // The entry stays: forgetting it would strand whatever is + // still on disk with nothing left tracking it. + failed_names.add(pack.name); + outcomes.push({name: pack.name, action: 'failed', detail: refusal}); + continue; + } + forget_for(pack.name); + outcomes.push({name: pack.name, action: 'removed', version: entry.version}); + } + return {...base, packs: outcomes, status: status_of(outcomes), hint: kept_hint(kept_names)}; + } + + // install and update both need the repository contents. update only touches + // packs the journal already knows about. + const targets = operation === 'update' + ? packs.filter(p=>entry_for(p.name)) + : packs; + const pending = targets.filter(p=>{ + const entry = entry_for(p.name); + return operation === 'update' || !entry || !entry.complete || entry.version !== p.version; + }); + for (const pack of targets) + { + if (!pending.includes(pack)) + { + outcomes.push({name: pack.name, action: 'current', version: pack.version}); + } + } + if (!pending.length) + { + return {...base, packs: outcomes}; + } + if (dry_run) + { + for (const pack of pending) + { + outcomes.push(copied_outcome(pack.name, pack.version, entry_for(pack.name))); + } + return {...base, packs: outcomes}; + } + + let cloned: Clone_result; + try { + cloned = await clone({ref, run, tmp_root}); + } catch (error) { + return { + ...base, + status: 'failed', + reason: 'clone-failed', + detail: (error as Error).message, + hint: 'install git (the flat-directory install clones the skills repository), then re-run', + }; + } + // Stamped only now that a clone actually happened this run — every + // return past this point reports the commit this run cloned, never a + // journal entry from some earlier run or a sibling host. + const cloned_base: Host_outcome = {...base, commit: cloned.commit}; + + // A per-host filesystem failure here (a read-only destination, a corrupt + // clone layout, a journal write error) must become a Host_outcome, never + // a rejected promise — one host failing must never abort the others, and + // any pack already installed and journaled before the failure still counts. + // + // A pack whose dependency failed (or was itself blocked) must never be + // attempted — mirrors adapter-native.ts's failed_names/blocked_names, so + // the invariant "never reply-adapter without ai-sdr-core" holds the same + // way regardless of which adapter is doing the installing. + const failed_names = new Set(); + const blocked_names = new Set(); + try { + for (const pack of pending) + { + const blocker = pack.dependencies.find(d=>failed_names.has(d) || blocked_names.has(d)); + if (blocker) + { + blocked_names.add(pack.name); + continue; + } + const from = path.join(cloned.dir, 'plugins', pack.name, 'skills'); + const previous = entry_for(pack.name); + const elsewhere = others_claim(pack.name); + const known_files = previous + ? [...previous.files.map(f=>path.resolve(f)), ...elsewhere] + : [...elsewhere]; + const skill_dirs = fs.readdirSync(from, {withFileTypes: true}).filter(e=>e.isDirectory()); + const collision = skill_dirs.find(skill=>{ + const dst_dir = path.join(target_root, skill.name); + return fs.existsSync(dst_dir) && !owns_dir(dst_dir, known_files); + }); + if (collision) + { + failed_names.add(pack.name); + outcomes.push({ + name: pack.name, + action: 'failed', + detail: `conflicts with an existing skill: ${collision.name}`, + }); + continue; + } + if (previous) + { + // Best-effort: a file that survives here is either overwritten + // by the copy below or fails it, and either way the copy's own + // error path — not a silent skip — is what reaches the user. + delete_files(previous.files, target_root, elsewhere); + } + const written: string[] = []; + try { + for (const skill of skill_dirs) + { + copy_dir(path.join(from, skill.name), path.join(target_root, skill.name), written); + } + } catch (copy_error) { + // Whatever landed before the failure — possibly nothing — is + // journaled as incomplete, never as done: a version match + // alone must never read as installed when the copy did not + // finish, or `install`'s own hint to re-run would do nothing. + record_for(pack.name, { + version: pack.version, ref, commit: cloned.commit, scope, + files: written, complete: false, installed_at: new Date().toISOString(), + }); + throw copy_error; + } + record_for(pack.name, { + version: pack.version, + ref, + commit: cloned.commit, + scope, + files: written, + complete: true, + installed_at: new Date().toISOString(), + }); + outcomes.push(copied_outcome(pack.name, pack.version, previous, cloned.commit)); + } + } catch (error) { + // outcomes.length is not "something landed" — every entry pushed so + // far could itself be a collision failure, so check the actions. + const landed = outcomes.some(p=>p.action !== 'failed'); + return { + ...cloned_base, + status: landed ? 'partial' : 'failed', + packs: outcomes, + reason: 'copy-failed', + detail: (error as Error).message, + hint: blocked_hint(blocked_names) ?? 'check filesystem permissions for the skills directory, then re-run', + }; + } finally { + try { + fs.rmSync(cloned.dir, {recursive: true, force: true}); + } catch { + // Best-effort cleanup of the clone's temp directory — never masks + // the result computed above. + } + } + return {...cloned_base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint(blocked_names)}; +}; + +export {clone_repo, copy_dir, skills_target, run_flat}; +export type {Clone_fn, Clone_result, Flat_opts}; diff --git a/src/skills/adapter-native.ts b/src/skills/adapter-native.ts new file mode 100644 index 0000000..c64f87f --- /dev/null +++ b/src/skills/adapter-native.ts @@ -0,0 +1,422 @@ +import {execFile} from 'child_process'; +import {MARKETPLACE, REPO} from './packs'; +import type {Detected_host} from './detect'; +import type {Host_outcome, Operation, Pack, Pack_outcome, Runner, Run_result, Scope} from './types'; + +// Drives a host's own plugin CLI rather than copying files, so Claude Code's +// marketplace update channel keeps working and Codex installs natively. + +const default_runner: Runner = (bin, args)=>new Promise(resolve=>{ + execFile(bin, args, {encoding: 'utf8', windowsHide: true}, (error, stdout, stderr)=>{ + const code = error && typeof (error as {code?: unknown}).code === 'number' + ? (error as unknown as {code: number}).code + : (error ? 1 : 0); + resolve({code, stdout: stdout ?? '', stderr: stderr ?? ''}); + }); +}); + +// Returns {ok: false, detail} if the listing failed or is unparseable. +// Returns {ok: true, versions} with only plugins from MARKETPLACE if the listing succeeded. +// Both hosts print a JSON listing, with different envelopes: Claude Code uses +// {plugins:[…]}, Codex uses {installed:[…]}. Rows carry marketplace metadata +// ('marketplace' for Claude Code, 'marketplaceName' for Codex) — only rows +// matching MARKETPLACE are included. +type Listing_result = {ok: true; versions: Record} | {ok: false; detail?: string}; + +const installed_versions = async(host: Detected_host, run: Runner): Promise=>{ + const bin = host.bin as string; + const result = await run(bin, host.def.cli!.list_json()); + if (result.code !== 0) + { + return {ok: false, detail: (result.stderr || result.stdout).trim()}; + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + return {ok: false}; + } + // Claude Code returns an array directly; older versions wrapped it in {plugins:[…]}. + // Codex wraps it in {installed:[…]}. + const doc = (parsed ?? {}) as Record; + const rows = Array.isArray(parsed) + ? parsed + : [doc.plugins, doc.installed].find(Array.isArray) as Record[] | undefined; + const out: Record = {}; + for (const row of rows ?? []) + { + // Claude Code uses 'id' (e.g. "agentic-runtime@reply-skills") instead of 'name'. + // Extract name from id if present, falling back to name field. + let name = (row.name ?? row.id) as string | undefined; + if (name && name.includes('@')) + { + const id_parts = name.split('@'); + const pack_name = id_parts[0]; + const marketplace = id_parts[1]; + // Only accept if marketplace matches or is empty + if (marketplace === MARKETPLACE || marketplace === undefined) + { + name = pack_name; + } + else + { + continue; + } + } + const version = row.version; + // Check marketplace: accept rows that declare MARKETPLACE, or rows with no marketplace field. + const marketplace = (row.marketplace ?? row.marketplaceName) as string | undefined; + if (typeof name === 'string' && typeof version === 'string') + { + // If marketplace is declared, it must match MARKETPLACE; if not declared, accept it. + if (marketplace === undefined || marketplace === MARKETPLACE) + { + out[name] = version; + } + } + } + return {ok: true, versions: out}; +}; + +type Native_opts = { + operation: Operation; + host: Detected_host; + packs: Pack[]; + scope: Scope; + run?: Runner; + dry_run?: boolean; +}; + +const skipped = (host: Detected_host): Host_outcome=>({ + host: host.def.id, + label: host.def.label, + kind: host.def.kind, + status: 'skipped', + reason: 'cli-not-resolved', + detail: `${host.def.label} config found at ${host.config_dir} but its CLI could not be resolved`, + hint: `add ${host.def.binaries[0]} to PATH, then re-run`, +}); + +const status_of = (packs: Pack_outcome[]): Host_outcome['status']=>{ + const failed = packs.filter(p=>p.action === 'failed'); + if (!failed.length) + { + return 'ok'; + } + return failed.length === packs.length ? 'failed' : 'partial'; +}; + +// The two dependency guards report the same way: name the packs the guard held +// back, so the user is told what is still to do rather than left to infer it. +// Install refuses a pack whose dependency failed; remove refuses a pack whose +// dependent failed. adapter-flat.ts carries the same pair verbatim — the two +// adapters are twin implementations of one rule, like status_of above. +const blocked_hint = (names: Iterable): string | undefined=>{ + const list = [...names]; + return list.length + ? `packs ${list.join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` + : undefined; +}; + +const kept_hint = (names: Iterable): string | undefined=>{ + const list = [...names]; + return list.length + ? `packs ${list.join(', ')} were kept because packs that depend on them could not be removed; fix those removals and re-run` + : undefined; +}; + +// A version that did not move is `current`, never `upgraded`. Every update +// path in both adapters answers this question through a helper like this one, +// so one machine can never report "already current" and "updated 0.1.0 → +// 0.1.0" for the same fact side by side. +const updated_outcome = (name: string, from: string, to: string): Pack_outcome=> + from === to + ? {name, action: 'current', version: to} + : {name, action: 'upgraded', version: to, from}; + +const run_native = async(opts: Native_opts): Promise=>{ + const {operation, host, packs, scope} = opts; + const run = opts.run ?? default_runner; + const dry_run = opts.dry_run === true; + const base: Host_outcome = { + host: host.def.id, label: host.def.label, kind: host.def.kind, scope, status: 'ok', + }; + if (!host.bin) + { + return skipped(host); + } + const cli = host.def.cli!; + + // Registering the marketplace is the precondition for every mutating + // operation; it is idempotent on both hosts. + if ((operation === 'install' || operation === 'update') && !dry_run) + { + const added = await run(host.bin, cli.marketplace_add(REPO)); + if (added.code !== 0) + { + return { + ...base, + status: 'failed', + reason: 'marketplace-add-failed', + detail: (added.stderr || added.stdout).trim(), + hint: `run \`${host.def.binaries[0]} ${cli.marketplace_add(REPO).join(' ')}\` manually to see why`, + }; + } + } + + const listing = await installed_versions(host, run); + const outcomes: Pack_outcome[] = []; + + if (operation === 'list') + { + if (!listing.ok) + { + return { + ...base, + status: 'failed', + reason: 'list-failed', + detail: listing.detail || 'failed to list installed plugins', + hint: `run \`${host.def.binaries[0]} ${cli.list_json().join(' ')}\` manually to see why`, + }; + } + const installed = listing.versions; + for (const pack of packs) + { + const have = installed[pack.name]; + if (!have) + { + continue; + } + outcomes.push(have === pack.version + ? {name: pack.name, action: 'current', version: have} + : {name: pack.name, action: 'upgraded', version: pack.version, from: have}); + } + return {...base, packs: outcomes}; + } + + if (operation === 'remove') + { + if (!listing.ok) + { + return { + ...base, + status: 'failed', + reason: 'list-failed', + detail: listing.detail || 'failed to list installed plugins', + hint: `run \`${host.def.binaries[0]} ${cli.list_json().join(' ')}\` manually to see why`, + }; + } + const installed = listing.versions; + // Reverse dependency order: a dependent never outlives its dependency. + // Ordering alone is not enough, though — if a dependent's removal + // fails, removing its dependency anyway leaves the host holding an + // adapter with no core, the one state this installer exists to + // prevent. So the install guard below is mirrored here, transposed: + // install refuses a pack whose dependency failed, remove refuses a + // pack whose dependent did. Reverse order means every dependent has + // already been visited by the time its dependency comes up, so the + // block propagates transitively down the chain. + const failed_names = new Set(); + const blocked_names = new Set(); + const kept_names = new Set(); + for (const pack of [...packs].reverse()) + { + const blocker = packs.find(p=>p.dependencies.includes(pack.name) + && (failed_names.has(p.name) || blocked_names.has(p.name))); + if (blocker) + { + blocked_names.add(pack.name); + // Only a pack that is actually here is "kept" — blocking one + // the host never had is bookkeeping for the chain, not news. + if (installed[pack.name]) + { + kept_names.add(pack.name); + } + continue; + } + if (!installed[pack.name]) + { + continue; + } + if (dry_run) + { + outcomes.push({name: pack.name, action: 'removed', version: installed[pack.name]}); + continue; + } + const result = await run(host.bin, cli.remove(pack.name, MARKETPLACE)); + if (result.code !== 0) + { + failed_names.add(pack.name); + outcomes.push({name: pack.name, action: 'failed', detail: (result.stderr || result.stdout).trim()}); + continue; + } + outcomes.push({name: pack.name, action: 'removed', version: installed[pack.name]}); + } + return {...base, packs: outcomes, status: status_of(outcomes), hint: kept_hint(kept_names)}; + } + + if (operation === 'update') + { + if (!listing.ok) + { + return { + ...base, + status: 'failed', + reason: 'list-failed', + detail: listing.detail || 'failed to list installed plugins', + hint: `run \`${host.def.binaries[0]} ${cli.list_json().join(' ')}\` manually to see why`, + }; + } + const installed = listing.versions; + const pre_update_versions = new Map(Object.entries(installed)); + + // Deduplicate updates: if update_scope is 'marketplace', run once and apply to all packs. + if (cli.update_scope === 'marketplace') + { + const installed_packs = packs.filter(p=>installed[p.name]); + if (installed_packs.length > 0) + { + if (dry_run) + { + for (const pack of installed_packs) + { + outcomes.push(updated_outcome(pack.name, installed[pack.name], pack.version)); + } + } + else + { + // Run the marketplace-wide update once + const result = await run(host.bin, cli.update(installed_packs[0].name, MARKETPLACE)); + if (result.code === 0) + { + // Re-read listing to get actual versions. A whole- + // marketplace upgrade exits 0 whether or not any pack + // moved, so the post-update versions — not the exit + // code — decide between `upgraded` and `current`. + const post_listing = await installed_versions(host, run); + if (post_listing.ok) + { + const post_installed = post_listing.versions; + for (const pack of installed_packs) + { + const have = pre_update_versions.get(pack.name); + outcomes.push(updated_outcome( + pack.name, have ?? '', post_installed[pack.name] ?? pack.version, + )); + } + } + else + { + // Re-read failed; use target version from registry + for (const pack of installed_packs) + { + const have = pre_update_versions.get(pack.name); + outcomes.push(updated_outcome(pack.name, have ?? '', pack.version)); + } + } + } + else + { + // Update failed; report all packs as failed with the same detail + for (const pack of installed_packs) + { + outcomes.push({name: pack.name, action: 'failed', detail: (result.stderr || result.stdout).trim()}); + } + } + } + } + } + else + { + // Per-pack updates (Claude Code) + for (const pack of packs) + { + const have = installed[pack.name]; + if (!have) + { + continue; + } + // Check if already at target version + if (have === pack.version) + { + outcomes.push({name: pack.name, action: 'current', version: have}); + continue; + } + if (dry_run) + { + outcomes.push({name: pack.name, action: 'upgraded', version: pack.version, from: have}); + continue; + } + const result = await run(host.bin, cli.update(pack.name, MARKETPLACE)); + if (result.code === 0) + { + // Re-read listing to get actual version. `plugin update` + // can exit 0 without moving the version, so this reports + // `current` rather than `upgraded 0.1.0 -> 0.1.0`. + const post_listing = await installed_versions(host, run); + if (post_listing.ok) + { + outcomes.push(updated_outcome( + pack.name, have, post_listing.versions[pack.name] ?? pack.version, + )); + } + else + { + // Re-read failed; use target version from registry + outcomes.push(updated_outcome(pack.name, have, pack.version)); + } + } + else + { + outcomes.push({name: pack.name, action: 'failed', detail: (result.stderr || result.stdout).trim()}); + } + } + } + return {...base, packs: outcomes, status: status_of(outcomes)}; + } + + // install — dependency order, and a failed dependency stops its dependents + // so the host is never left with an adapter and no core. Track both failed + // and blocked packs to handle transitive dependency chains. + const installed = listing.ok ? listing.versions : {}; + const failed_names = new Set(); + const blocked_names = new Set(); + for (const pack of packs) + { + const blocker = pack.dependencies.find(d=>failed_names.has(d) || blocked_names.has(d)); + if (blocker) + { + blocked_names.add(pack.name); + continue; + } + const have = installed[pack.name]; + if (have === pack.version) + { + outcomes.push({name: pack.name, action: 'current', version: have}); + continue; + } + const action: Pack_outcome['action'] = have ? 'upgraded' : 'installed'; + if (dry_run) + { + outcomes.push(have + ? {name: pack.name, action, version: pack.version, from: have} + : {name: pack.name, action, version: pack.version}); + continue; + } + const result = await run(host.bin, cli.install(pack.name, MARKETPLACE, scope)); + if (result.code !== 0) + { + failed_names.add(pack.name); + outcomes.push({name: pack.name, action: 'failed', detail: (result.stderr || result.stdout).trim()}); + continue; + } + outcomes.push(have + ? {name: pack.name, action, version: pack.version, from: have} + : {name: pack.name, action, version: pack.version}); + } + + return {...base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint(blocked_names)}; +}; + +export {default_runner, installed_versions, run_native}; +export type {Native_opts}; diff --git a/src/skills/detect.ts b/src/skills/detect.ts new file mode 100644 index 0000000..9b6265c --- /dev/null +++ b/src/skills/detect.ts @@ -0,0 +1,129 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {execFileSync} from 'child_process'; +import {HOSTS, host_by_id} from './hosts'; +import type {Host_def} from './types'; + +type Detected_host = { + def: Host_def; + // Resolved binary for a native host; undefined means "present but not + // runnable", which the adapter turns into an actionable skip. + bin?: string; + config_dir: string; +}; + +type Detect_deps = { + home: string; + platform: NodeJS.Platform; + exists: (p: string)=>boolean; + find_on_path: (name: string)=>string | undefined; + glob_first: (pattern: string)=>string | undefined; +}; + +// Expands one '*' segment by listing its parent — enough for Codex's +// hash-named bin directory, and no dependency on a glob library. +const glob_first_real = (pattern: string): string | undefined=>{ + const star = pattern.indexOf('*'); + if (star < 0) + { + return fs.existsSync(pattern) ? pattern : undefined; + } + const parent = pattern.slice(0, star).replace(/[\\/]+$/, ''); + const tail = pattern.slice(pattern.indexOf(path.sep, star) + 1); + let entries: string[]; + try { + entries = fs.readdirSync(parent); + } catch { + return undefined; + } + for (const entry of entries) + { + const candidate = path.join(parent, entry, tail); + if (fs.existsSync(candidate)) + { + return candidate; + } + } + return undefined; +}; + +const find_on_path_real = (name: string): string | undefined=>{ + const probe = process.platform === 'win32' ? 'where' : 'which'; + try { + const out = execFileSync(probe, [name], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}); + const first = out.split(/\r?\n/).map(l=>l.trim()).filter(Boolean)[0]; + return first || undefined; + } catch { + return undefined; + } +}; + +const default_detect_deps = (): Detect_deps=>({ + home: os.homedir(), + platform: process.platform, + exists: (p)=>fs.existsSync(p), + find_on_path: find_on_path_real, + glob_first: glob_first_real, +}); + +const resolve_bin = (def: Host_def, deps: Detect_deps): string | undefined=>{ + for (const name of def.binaries) + { + const found = deps.find_on_path(name); + if (found) + { + return found; + } + } + // PATH is not enough: Codex on Windows ships with the desktop app and is + // absent from PATH on a machine that clearly has it. + for (const pattern of def.binary_paths) + { + const found = deps.glob_first(pattern.replace('{home}', deps.home)); + if (found) + { + return found; + } + } + return undefined; +}; + +// Presence is decided by the host's configuration directory. A native host +// whose binary cannot be resolved is still detected — that is a fixable +// situation the user needs to hear about, not an absence. +const detect_hosts = (deps: Detect_deps = default_detect_deps()): Detected_host[]=>{ + const found: Detected_host[] = []; + for (const def of HOSTS) + { + const dir = def.config_dirs + .map(rel=>path.join(deps.home, rel)) + .find(full=>deps.exists(full)); + if (!dir) + { + continue; + } + found.push({def, config_dir: dir, bin: resolve_bin(def, deps)}); + } + return found; +}; + +// `--agent` overrides detection. Unknown ids are a usage error; known ids that +// are not present are returned separately so the report can say so. +const select_hosts = ( + ids: string[] | undefined, + deps: Detect_deps = default_detect_deps(), +): {selected: Detected_host[]; missing: Host_def[]}=>{ + const detected = detect_hosts(deps); + if (!ids || !ids.length) + { + return {selected: detected, missing: []}; + } + const wanted = ids.map(host_by_id); + const selected = detected.filter(d=>wanted.some(w=>w.id === d.def.id)); + const missing = wanted.filter(w=>!detected.some(d=>d.def.id === w.id)); + return {selected, missing}; +}; + +export {default_detect_deps, detect_hosts, select_hosts}; +export type {Detected_host, Detect_deps}; diff --git a/src/skills/hosts.ts b/src/skills/hosts.ts new file mode 100644 index 0000000..e88c44d --- /dev/null +++ b/src/skills/hosts.ts @@ -0,0 +1,118 @@ +import path from 'path'; +import {UsageError} from '../utils/errors'; +import type {Host_cli, Host_def} from './types'; + +// One entry per assistant. Adding a host is a data change plus a verification +// run — never a branch inside an adapter. `verified: false` means the paths come +// from documentation we have not confirmed by installing (see REPLY-51268). +// +// {home} in binary_paths is expanded by detect.ts; a leading '~/' is not used +// so the strings stay platform-agnostic. + +const claude_cli: Host_cli = { + marketplace_add: (repo)=>['plugin', 'marketplace', 'add', repo], + list_json: ()=>['plugin', 'list', '--json'], + install: (pack, marketplace, scope)=>['plugin', 'install', `${pack}@${marketplace}`, '--scope', scope], + update: (pack, marketplace)=>['plugin', 'update', `${pack}@${marketplace}`], + remove: (pack, marketplace)=>['plugin', 'uninstall', `${pack}@${marketplace}`], + update_scope: 'pack', +}; + +// Codex spells the same operations differently and takes --json on all of them. +const codex_cli: Host_cli = { + marketplace_add: (repo)=>['plugin', 'marketplace', 'add', repo, '--json'], + list_json: ()=>['plugin', 'list', '--json'], + install: (pack, marketplace)=>['plugin', 'add', `${pack}@${marketplace}`, '--json'], + update: (_pack, marketplace)=>['plugin', 'marketplace', 'upgrade', marketplace, '--json'], + remove: (pack, marketplace)=>['plugin', 'remove', `${pack}@${marketplace}`, '--json'], + update_scope: 'marketplace', +}; + +const HOSTS: Host_def[] = [ + { + id: 'claude-code', + label: 'Claude Code', + kind: 'native-plugin', + config_dirs: ['.claude'], + binaries: ['claude'], + binary_paths: [], + cli: claude_cli, + project_skills_dir: path.join('.claude', 'skills'), + verified: true, + }, + { + id: 'codex', + label: 'Codex', + kind: 'native-plugin', + config_dirs: ['.codex'], + binaries: ['codex'], + // Windows ships Codex with the desktop app, off PATH; the hash segment + // varies, so detect.ts globs one level. + binary_paths: [path.join('{home}', 'AppData', 'Local', 'OpenAI', 'Codex', 'bin', '*', 'codex.exe')], + cli: codex_cli, + // Codex's plugin mechanism is user-scoped, so --project falls back to + // copying into the repository's .agents/skills. + project_skills_dir: path.join('.agents', 'skills'), + verified: true, + }, + { + id: 'cursor', + label: 'Cursor', + kind: 'flat-skills-dir', + config_dirs: ['.cursor'], + binaries: [], + binary_paths: [], + user_skills_dir: path.join('.cursor', 'skills'), + project_skills_dir: path.join('.agents', 'skills'), + verified: false, + }, + { + id: 'gemini-cli', + label: 'Gemini CLI', + kind: 'flat-skills-dir', + config_dirs: ['.gemini'], + binaries: [], + binary_paths: [], + user_skills_dir: path.join('.gemini', 'skills'), + project_skills_dir: path.join('.agents', 'skills'), + verified: false, + }, + { + id: 'github-copilot', + label: 'GitHub Copilot', + kind: 'flat-skills-dir', + config_dirs: ['.copilot'], + binaries: [], + binary_paths: [], + user_skills_dir: path.join('.copilot', 'skills'), + project_skills_dir: path.join('.agents', 'skills'), + verified: false, + }, + { + id: 'windsurf', + label: 'Windsurf', + kind: 'flat-skills-dir', + config_dirs: [path.join('.codeium', 'windsurf')], + binaries: [], + binary_paths: [], + user_skills_dir: path.join('.codeium', 'windsurf', 'skills'), + project_skills_dir: path.join('.windsurf', 'skills'), + verified: false, + }, +]; + +const host_ids = (): string[]=>HOSTS.map(h=>h.id); + +const host_by_id = (id: string): Host_def=>{ + const found = HOSTS.find(h=>h.id === id); + if (!found) + { + throw new UsageError(`Unknown assistant '${id}'.`, { + code: 'usage.skills_agent', + hint: `Known assistants: ${host_ids().join(', ')}`, + }); + } + return found; +}; + +export {HOSTS, host_ids, host_by_id}; diff --git a/src/skills/journal.ts b/src/skills/journal.ts new file mode 100644 index 0000000..e92ba3f --- /dev/null +++ b/src/skills/journal.ts @@ -0,0 +1,133 @@ +import fs from 'fs'; +import path from 'path'; +import {skills_file} from '../config'; +import {RuntimeError} from '../utils/errors'; +import type {Env} from '../config'; +import type {Scope} from './types'; + +// Installer-side state, and only for flat-directory hosts: native hosts are +// asked directly (`plugin list --json`), so there is no second copy of the +// truth to drift. This records what we wrote so `update` is idempotent and +// `remove` deletes our files and nothing else. + +type Journal_entry = { + version: string; + ref: string; + commit?: string; + scope: Scope; + // The resolved project root a project-scope entry belongs to. The key + // below is host -> scope -> pack, which cannot tell two checkouts apart: + // without this, `remove --project` run from a second repository finds the + // first one's entry, deletes nothing (containment refuses every path) and + // still forgets the entry. Absent on user-scope entries, whose directory + // is the home directory and therefore unambiguous. + project_root?: string; + // Absolute paths written by the flat adapter. + files: string[]; + // False when the copy this entry describes did not finish — a version + // match alone is not enough to call a pack installed, since a failed + // copy can land partway through, at the target version, with some files + // on disk and some not. An incomplete entry must never be reported + // `current` and must always be treated as work still to do. + complete: boolean; + installed_at: string; +}; + +// Keyed host -> scope -> pack, so a user-scope install and a project-scope +// install of the same pack on the same host never share an entry: consulting +// or forgetting one must never look, or delete, in the other's target +// directory (see adapter-flat.ts, which resolves a different directory per +// scope). +type Journal = { + version: 1; + hosts: Record>>; +}; + +const CORRUPT_HINT = 'Delete the file and re-run `reply skills install`.'; + +const read_journal = (env?: Env): Journal=>{ + const file = skills_file(env); + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') + { + return {version: 1, hosts: {}}; + } + throw new RuntimeError('Could not read the skills journal.', { + code: 'skills.journal_read', + detail: file, + hint: (e as Error).message, + }); + } + if (!raw.trim()) + { + return {version: 1, hosts: {}}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new RuntimeError('The skills journal is corrupt (invalid JSON).', { + code: 'skills.journal_corrupt', + detail: file, + hint: CORRUPT_HINT, + }); + } + const doc = parsed as Journal; + if (!doc || typeof doc !== 'object' || Array.isArray(doc) || typeof doc.hosts !== 'object' || !doc.hosts) + { + throw new RuntimeError('The skills journal is corrupt (unexpected shape).', { + code: 'skills.journal_corrupt', + detail: file, + hint: CORRUPT_HINT, + }); + } + return {version: 1, hosts: doc.hosts}; +}; + +const write_journal = (journal: Journal, env?: Env): void=>{ + const file = skills_file(env); + const dir = path.dirname(file); + fs.mkdirSync(dir, {recursive: true, mode: 0o700}); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(journal, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, file); +}; + +const journal_entry = (host: string, scope: Scope, pack: string, env?: Env): Journal_entry | undefined=> + read_journal(env).hosts[host]?.[scope]?.[pack]; + +const record_pack = (host: string, scope: Scope, pack: string, entry: Journal_entry, env?: Env): void=>{ + const journal = read_journal(env); + const scopes = journal.hosts[host] ?? {}; + scopes[scope] = {...(scopes[scope] ?? {}), [pack]: entry}; + journal.hosts[host] = scopes; + write_journal(journal, env); +}; + +const forget_pack = (host: string, scope: Scope, pack: string, env?: Env): Journal_entry | undefined=>{ + const journal = read_journal(env); + const scopes = journal.hosts[host]; + const packs = scopes?.[scope]; + const existing = packs?.[pack]; + if (!existing) + { + return undefined; + } + delete packs[pack]; + if (!Object.keys(packs).length) + { + delete scopes![scope]; + } + if (scopes && !Object.keys(scopes).length) + { + delete journal.hosts[host]; + } + write_journal(journal, env); + return existing; +}; + +export {read_journal, write_journal, journal_entry, record_pack, forget_pack}; +export type {Journal, Journal_entry}; diff --git a/src/skills/orchestrate.ts b/src/skills/orchestrate.ts new file mode 100644 index 0000000..9278a8a --- /dev/null +++ b/src/skills/orchestrate.ts @@ -0,0 +1,162 @@ +import os from 'os'; +import {run_flat, type Clone_fn} from './adapter-flat'; +import {run_native} from './adapter-native'; +import {default_detect_deps, select_hosts, type Detect_deps} from './detect'; +import {DEFAULT_REF, REPO, load_packs, resolve_packs} from './packs'; +import {summarize} from './report'; +import {UsageError} from '../utils/errors'; +import type {Env} from '../config'; +import type {Host_def, Host_outcome, Operation, Pack, Report, Runner, Scope} from './types'; + +// The one flow all four commands share: detect hosts, resolve packs in +// dependency order, run the right adapter per host, collect outcomes. Every +// external dependency is injectable so the whole thing is testable offline. + +type Skills_deps = { + detect?: Detect_deps; + run?: Runner; + clone?: Clone_fn; + home?: string; + cwd?: string; + tmp_root?: string; + env?: Env; + fetch_impl?: typeof fetch; + ref?: string; +}; + +type Skills_opts = { + operation: Operation; + requested: string[]; + agents?: string[]; + project: boolean; + dry_run: boolean; + deps?: Skills_deps; +}; + +// A dependency may not be removed while something that needs it could stay +// behind: that is exactly the adapter-without-core state the installer exists to +// avoid. Deliberately conservative — it refuses based on the dependency graph +// alone, without first asking every host what it has installed. A selective +// removal is the rare path, the fix is one flag away, and being wrong in this +// direction only costs an extra keystroke, while being wrong in the other +// direction breaks a working assistant. +const guard_remove = (packs: Pack[], all: Pack[]): void=>{ + const going = new Set(packs.map(p=>p.name)); + for (const pack of all) + { + if (going.has(pack.name)) + { + continue; + } + const needed = pack.dependencies.filter(d=>going.has(d)); + if (needed.length) + { + throw new UsageError( + `${pack.name} depends on ${needed.join(', ')}; removing it alone can leave that pack broken.`, + { + code: 'usage.skills_remove', + hint: `remove ${pack.name} too, or run \`reply skills remove\` to remove everything`, + }, + ); + } + } +}; + +const not_detected = (def: Host_def): Host_outcome=>({ + host: def.id, label: def.label, kind: def.kind, status: 'skipped', reason: 'not-detected', + detail: `${def.label} was requested with --agent but is not installed on this machine`, + verified: def.verified, +}); + +const run_skills = async(opts: Skills_opts): Promise=>{ + const deps = opts.deps ?? {}; + const detect = deps.detect ?? default_detect_deps(); + const ref = deps.ref ?? DEFAULT_REF; + const registry = await load_packs({ref, fetch_impl: deps.fetch_impl}); + // Install and update pull dependencies; remove must not — see resolve_packs. + const packs = resolve_packs(opts.requested, registry, { + dependencies: opts.operation !== 'remove', + }); + const scope: Scope = opts.project ? 'project' : 'user'; + + if (opts.operation === 'remove' && opts.requested.length) + { + guard_remove(packs, registry.packs); + } + + const {selected, missing} = select_hosts(opts.agents, detect); + const hosts: Host_outcome[] = []; + + for (const host of selected) + { + // A native host under --project falls back to the flat adapter only + // when its plugin mechanism cannot express project scope; Claude Code + // can, Codex cannot. + const native = host.def.kind === 'native-plugin' + && !(scope === 'project' && host.def.id === 'codex'); + let outcome: Host_outcome; + try { + outcome = native + ? await run_native({ + operation: opts.operation, host, packs, scope, + run: deps.run, dry_run: opts.dry_run, + }) + : await run_flat({ + operation: opts.operation, host, packs, scope, ref, + run: deps.run, clone: deps.clone, dry_run: opts.dry_run, + home: deps.home ?? detect.home, cwd: deps.cwd, + tmp_root: deps.tmp_root ?? os.tmpdir(), env: deps.env, + }); + } catch (error) { + // An adapter is expected to turn every failure it knows about + // into a Host_outcome; this is the backstop for the ones it + // doesn't (a journal write racing an antivirus scanner, a corrupt + // journal file) — one host's surprise must never take the others + // down with it. + outcome = { + host: host.def.id, label: host.def.label, kind: host.def.kind, scope, + status: 'failed', + reason: 'host-error', + detail: (error as Error).message, + hint: `re-run \`reply skills ${opts.operation}\` once the underlying error for ${host.def.label} is resolved`, + }; + } + // Stamped here rather than in each adapter: whether an assistant's + // paths have been confirmed is registry data, not something an + // adapter computes, and doing it once means no path can forget it. + hosts.push({...outcome, verified: host.def.verified}); + } + for (const def of missing) + { + hosts.push(not_detected(def)); + } + + // The commit is only known when this run itself cloned something: a flat + // host stamps its own outcome with the commit it cloned (see + // adapter-flat.ts), and only then — never on a native host, a skip, or a + // failure that never reached a clone. Reading it off the outcomes (rather + // than the journal) means a stale entry from an earlier run, or one that + // belongs to a different host, can never be attributed to this run. If + // flat hosts somehow disagree — cloning at different moments within the + // same run — no commit is reported rather than an arbitrary one. + const commits = new Set(hosts.map(h=>h.commit).filter((c): c is string=>!!c)); + const commit = commits.size === 1 ? [...commits][0] : undefined; + + // `requested` is what the user asked for in canonical form; `resolved` is + // that plus dependencies. Both are reported so an agent sees the pull + // without parsing prose. resolve_packs puts dependencies first, so the + // requested pack itself is always the last element. + const canonical = opts.requested.map(r=>resolve_packs([r], registry).slice(-1)[0].name); + + return { + action: opts.operation, + source: commit ? {repo: REPO, ref, commit} : {repo: REPO, ref}, + requested: opts.requested.length ? canonical : packs.map(p=>p.name), + resolved: packs.map(p=>p.name), + hosts, + summary: summarize(hosts, opts.operation), + }; +}; + +export {guard_remove, run_skills}; +export type {Skills_deps, Skills_opts}; diff --git a/src/skills/packs.ts b/src/skills/packs.ts new file mode 100644 index 0000000..ba5a3b7 --- /dev/null +++ b/src/skills/packs.ts @@ -0,0 +1,174 @@ +import {UsageError, RuntimeError} from '../utils/errors'; +import type {Pack, Pack_registry} from './types'; + +// Identity of the knowledge repository. packs.json in that repo is the +// host-neutral source of truth for pack identity and the dependency graph; +// PACKS_FALLBACK is a build-time copy so `install` still works offline. +const REPO = 'reply-team/reply-skills'; +const MARKETPLACE = 'reply-skills'; +const DEFAULT_REF = 'main'; + +const ALIASES: Record = { + core: 'ai-sdr-core', + adapter: 'reply-adapter', + runtime: 'agentic-runtime', +}; + +const PACKS_FALLBACK: Pack_registry = { + marketplace: MARKETPLACE, + packs: [ + { + name: 'ai-sdr-core', + display_name: 'AI SDR Core', + version: '0.1.0', + description: 'Vendor-neutral AI SDR expertise: the operation contract, playbooks and guardrails.', + dependencies: [], + }, + { + name: 'reply-adapter', + display_name: 'Reply.io Adapter', + version: '0.1.0', + description: 'Executes the AI SDR Core contract against Reply.io: CLI, API v3, MCP, auth.', + dependencies: ['ai-sdr-core'], + }, + { + name: 'agentic-runtime', + display_name: 'Agentic Runtime', + version: '0.1.0', + description: 'Durable multi-session work: plans, work items, checkpoints, reports, memory.', + dependencies: ['ai-sdr-core'], + }, + ], +}; + +const packs_url = (ref: string = DEFAULT_REF): string=> + `https://raw.githubusercontent.com/${REPO}/${ref}/packs.json`; + +const bad_document = (detail: string): RuntimeError=> + new RuntimeError('The skills pack registry is malformed.', {code: 'skills.packs_malformed', detail}); + +const as_string = (value: unknown, field: string): string=>{ + if (typeof value !== 'string' || !value.trim()) + { + throw bad_document(`missing or non-string field: ${field}`); + } + return value; +}; + +// Parses reply-skills' packs.json. Rejects the document as a whole rather than +// returning a half-parsed registry — a partially understood dependency graph is +// worse than no graph. +const parse_packs = (raw: unknown): Pack_registry=>{ + const doc = (raw ?? {}) as Record; + const marketplace = (doc.marketplace ?? {}) as Record; + const entries = doc.packs; + if (!Array.isArray(entries) || !entries.length) + { + throw bad_document('packs must be a non-empty array'); + } + const packs: Pack[] = entries.map((entry, i)=>{ + const e = (entry ?? {}) as Record; + const name = as_string(e.name, `packs[${i}].name`); + const deps = e.dependencies === undefined ? [] : e.dependencies; + if (!Array.isArray(deps) || deps.some(d=>typeof d !== 'string')) + { + throw bad_document(`packs[${i}].dependencies must be an array of strings`); + } + return { + name, + display_name: typeof e.displayName === 'string' ? e.displayName : name, + version: as_string(e.version, `packs[${i}].version`), + description: typeof e.description === 'string' ? e.description : '', + dependencies: deps as string[], + }; + }); + const known = new Set(packs.map(p=>p.name)); + for (const pack of packs) + { + for (const dep of pack.dependencies) + { + if (!known.has(dep)) + { + throw bad_document(`${pack.name} depends on unknown pack '${dep}'`); + } + } + } + return {marketplace: as_string(marketplace.name ?? MARKETPLACE, 'marketplace.name'), packs}; +}; + +// Fetches the registry, degrading to the embedded copy on any problem — +// unreachable network, HTTP error, or a document we cannot trust. Installing a +// known-good pack set beats failing because GitHub is having a bad day. +const load_packs = async(opts: {ref?: string; fetch_impl?: typeof fetch} = {}): Promise=>{ + const fetch_impl = opts.fetch_impl ?? fetch; + try { + const response = await fetch_impl(packs_url(opts.ref)); + if (!response.ok) + { + return PACKS_FALLBACK; + } + return parse_packs(await response.json()); + } catch { + return PACKS_FALLBACK; + } +}; + +// Requested names (aliases allowed, empty means everything) → the packs to act +// on, dependencies always before their dependents. +// +// `dependencies: false` returns exactly the requested packs, still in registry +// order. Removal uses it: `remove reply-adapter` must not drag `ai-sdr-core` +// along, because other installed packs still need it. +const resolve_packs = ( + requested: string[], + registry: Pack_registry, + opts: {dependencies?: boolean} = {}, +): Pack[]=>{ + const by_name = new Map(registry.packs.map(p=>[p.name, p])); + const valid = registry.packs.map(p=>p.name).join(', '); + const wanted = requested.length + ? requested.map(raw=>{ + const name = ALIASES[raw] ?? raw; + if (!by_name.has(name)) + { + throw new UsageError(`Unknown skills pack '${raw}'.`, { + code: 'usage.skills_pack', + hint: `Valid packs: ${valid} (aliases: ${Object.keys(ALIASES).join(', ')})`, + }); + } + return name; + }) + : registry.packs.map(p=>p.name); + + if (opts.dependencies === false) + { + const chosen = new Set(wanted); + return registry.packs.filter(p=>chosen.has(p.name)); + } + + const ordered: Pack[] = []; + const seen = new Set(); + const visit = (name: string): void=>{ + if (seen.has(name)) + { + return; + } + seen.add(name); + const pack = by_name.get(name) as Pack; + for (const dep of pack.dependencies) + { + visit(dep); + } + ordered.push(pack); + }; + for (const name of wanted) + { + visit(name); + } + return ordered; +}; + +export { + REPO, MARKETPLACE, DEFAULT_REF, ALIASES, PACKS_FALLBACK, + packs_url, parse_packs, load_packs, resolve_packs, +}; diff --git a/src/skills/report.ts b/src/skills/report.ts new file mode 100644 index 0000000..c919725 --- /dev/null +++ b/src/skills/report.ts @@ -0,0 +1,123 @@ +import {pc} from '../utils/output'; +import type {Host_outcome, Operation, Pack_action, Report} from './types'; + +// Turns per-host outcomes into what the user reads and what the process +// returns. The report names only what was found: an assistant that is not on +// the machine is never mentioned. + +// `list` is a query: a host answering "ok" says nothing about whether any pack +// is actually there. For every other operation, "installed" is the outcome +// status (something landed, or didn't); for `list`, it is whether a pack was +// actually found — skipped/failed keep their host-oriented meaning either way. +const summarize = (hosts: Host_outcome[], action: Operation): Report['summary']=>({ + installed: action === 'list' + ? hosts.filter(h=>(h.packs?.length ?? 0) > 0).length + : hosts.filter(h=>h.status === 'ok' || h.status === 'partial').length, + skipped: hosts.filter(h=>h.status === 'skipped').length, + failed: hosts.filter(h=>h.status === 'failed').length, +}); + +const dependency_note = (requested: string[], resolved: string[]): string | undefined=>{ + const added = resolved.filter(name=>!requested.includes(name)); + if (!added.length || !requested.length) + { + return undefined; + } + return `${added.join(', ')} added — required by ${requested.join(', ')}`; +}; + +const verb = { + installed: 'installed', upgraded: 'updated', removed: 'removed', + current: 'already current', failed: 'failed', +} as const; + +// `list` never changes anything: an 'upgraded' pack there means a newer +// version is available, not that an update already happened underfoot. +const pack_verb = (report_action: Operation, pack_action: Pack_action): string=> + report_action === 'list' && pack_action === 'upgraded' ? 'update available' : verb[pack_action]; + +// Groups a host's packs by what happened, so one host is one line. +const host_line = (host: Host_outcome, report_action: Operation): string=>{ + const label = host.label.padEnd(12); + if (host.status === 'skipped') + { + return pc.yellow(`⚠ ${label}· skipped — ${host.detail ?? host.reason ?? 'not usable'}`); + } + if (host.status === 'failed' && !host.packs?.length) + { + return pc.yellow(`⚠ ${label}· failed — ${host.detail ?? host.reason ?? 'unknown error'}`); + } + const groups = new Map(); + for (const pack of host.packs ?? []) + { + const key = pack_verb(report_action, pack.action); + groups.set(key, [...(groups.get(key) ?? []), pack.name]); + } + if (!groups.size) + { + return pc.dim(`· ${label}· nothing to do`); + } + const parts = [...groups.entries()].map(([action, names])=>`${names.join(', ')} ${action}`); + const mark = host.status === 'ok' ? pc.green('✓') : pc.yellow('⚠'); + // A confident tick on a host whose skills directory we have never + // confirmed the assistant reads from would overstate what we know + // (REPLY-51268). Only said where a claim is actually being made. + const note = host.verified === false ? pc.dim(' (paths not yet verified)') : ''; + return `${mark} ${label}· ${parts.join('; ')}${note}`; +}; + +// Whether this run put anything new in front of the assistant, and so whether +// the user has to start a new session. The action labels alone cannot answer +// it: an unchanged version reports `current` in every adapter, which is right, +// but a flat host re-copying a newer commit at that same version did rewrite +// the files. `refreshed` carries exactly that, so both facts are consulted. +const changed = (report: Report): boolean=>report.hosts.some(h=> + (h.packs ?? []).some(p=> + p.action === 'installed' || p.action === 'upgraded' || p.refreshed === true)); + +const human_lines = (report: Report): string[]=>{ + const lines: string[] = []; + if (!report.hosts.length) + { + lines.push(pc.yellow('⚠ no supported assistant found on this machine')); + lines.push(pc.dim(' Install Claude Code or Codex, or pass --agent to name one explicitly.')); + return lines; + } + lines.push(pc.green(`✓ detected ${report.hosts.map(h=>h.label).join(', ')}`)); + const note = dependency_note(report.requested, report.resolved); + if (note && report.action === 'install') + { + lines.push(pc.dim(` ${note}`)); + } + for (const host of report.hosts) + { + lines.push(host_line(host, report.action)); + if (host.hint) + { + lines.push(pc.dim(` fix: ${host.hint}`)); + } + // Surface pack-level details for failed packs + const failed_packs = (host.packs ?? []).filter(p=>p.action === 'failed' && p.detail); + for (const pack of failed_packs) + { + lines.push(pc.dim(` ${pack.name}: ${pack.detail}`)); + } + } + if (changed(report) && report.action !== 'list') + { + lines.push(pc.dim('Start a new session in each assistant so the skills load.')); + } + return lines; +}; + +// Best-effort across hosts: one host failing does not fail the command, but +// installing nowhere does. `list` is a query, so it is never a failure. +const exit_code_for = (report: Report): number=>{ + if (report.action === 'list') + { + return 0; + } + return report.summary.installed > 0 ? 0 : 1; +}; + +export {summarize, dependency_note, host_line, human_lines, exit_code_for}; diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..a86e2ef --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,119 @@ +// Contracts shared by the skills installer. Types only — no logic, so any +// module can import this without pulling in behaviour. + +type Pack = { + name: string; + display_name: string; + version: string; + description: string; + dependencies: string[]; +}; + +type Pack_registry = { + marketplace: string; + packs: Pack[]; +}; + +type Scope = 'user' | 'project'; + +type Host_kind = 'native-plugin' | 'flat-skills-dir'; + +// Argument vectors for a host's own plugin CLI, minus the resolved binary. +// Kept as data so a new host is a registry entry, not a branch in the adapter. +type Host_cli = { + marketplace_add: (repo: string)=>string[]; + list_json: ()=>string[]; + install: (pack: string, marketplace: string, scope: Scope)=>string[]; + update: (pack: string, marketplace: string)=>string[]; + remove: (pack: string, marketplace: string)=>string[]; + // Scope of the update operation: 'pack' for per-pack updates (Claude Code), + // 'marketplace' for whole-marketplace updates (Codex). + update_scope: 'pack' | 'marketplace'; +}; + +type Host_def = { + id: string; + label: string; + kind: Host_kind; + // Paths relative to the home directory; the first that exists proves presence. + config_dirs: string[]; + // Command names to look for on PATH (native hosts). + binaries: string[]; + // Absolute-path candidates for hosts that ship off PATH, with {home} expanded. + binary_paths: string[]; + cli?: Host_cli; + // Skills directory relative to the home directory (flat hosts). + user_skills_dir?: string; + // Skills directory relative to the project root (flat hosts, and native + // hosts whose plugin mechanism cannot express a project install). + project_skills_dir?: string; + verified: boolean; +}; + +type Pack_action = 'installed' | 'upgraded' | 'current' | 'removed' | 'failed'; + +type Pack_outcome = { + name: string; + action: Pack_action; + version?: string; + from?: string; + detail?: string; + // Set by the flat adapter on a `current` pack whose files this run + // rewrote anyway: a newer commit on the same ref, or a repair of an + // install that never finished. The version did not move — so the action + // is `current`, never `upgraded` — but the bytes on disk did, and that is + // what decides whether the user has to start a new assistant session. + // Never set by a native host, which cannot see below its own plugin CLI, + // and never by a dry run, which does not clone and so cannot know. + refreshed?: boolean; +}; + +type Host_status = 'ok' | 'partial' | 'failed' | 'skipped'; + +type Host_outcome = { + host: string; + label: string; + kind: Host_kind; + scope?: Scope; + status: Host_status; + packs?: Pack_outcome[]; + reason?: string; + detail?: string; + hint?: string; + // The commit a flat host actually cloned in this run — set only when this + // run cloned the skills repository (never on a native host, and never + // when nothing was pending, a dry run skipped the clone, or the clone + // itself failed). This is what lets the orchestrator report the source + // commit without ever attributing a stale or foreign one to a run that + // did not produce it. + commit?: string; + // Mirrors Host_def.verified: false means this assistant's paths come from + // its documentation and have not been confirmed by a verification run of + // our own (REPLY-51268). Stamped by the orchestrator for every host it + // reports, so no adapter can forget it and a --json consumer can tell a + // confirmed success from an unconfirmed one. + verified?: boolean; +}; + +type Operation = 'install' | 'list' | 'update' | 'remove'; + +type Report = { + action: Operation; + source: {repo: string; ref: string; commit?: string}; + requested: string[]; + resolved: string[]; + hosts: Host_outcome[]; + summary: {installed: number; skipped: number; failed: number}; +}; + +type Run_result = {code: number; stdout: string; stderr: string}; + +// Injected process runner: the single seam that lets every adapter test run +// without an assistant installed. +type Runner = (bin: string, args: string[])=>Promise; + +export type { + Pack, Pack_registry, Scope, Host_kind, Host_cli, Host_def, + Pack_action, Pack_outcome, Host_status, Host_outcome, + Operation, Report, Run_result, Runner, +};