From 48c8d082110f216d779a5f154237a9c6b276e613 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 14:27:44 -0700 Subject: [PATCH 01/32] chore: ignore the local .superpowers agent workspace Agent scratch (ledgers, task briefs, review packages) lives under .superpowers/ in the working tree; it must never reach a commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index edc5d77..febe22a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.tgz coverage/ +.superpowers/ From a98c89e52c8e1d6e19dc967a66871e49c8fa9d28 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 14:30:37 -0700 Subject: [PATCH 02/32] feat: skills pack registry and dependency resolution --- src/__tests__/skills/packs.test.ts | 120 ++++++++++++++++++++ src/skills/packs.ts | 174 +++++++++++++++++++++++++++++ src/skills/types.ts | 95 ++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 src/__tests__/skills/packs.test.ts create mode 100644 src/skills/packs.ts create mode 100644 src/skills/types.ts 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/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/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..4c634c8 --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,95 @@ +// 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[]; +}; + +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; +}; + +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; +}; + +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, +}; From dd199ab50305bb860f22106b897cbd6a414f870f Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 14:37:24 -0700 Subject: [PATCH 03/32] feat: skills host registry for six assistants --- src/__tests__/skills/hosts.test.ts | 66 ++++++++++++++++ src/skills/hosts.ts | 116 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/__tests__/skills/hosts.test.ts create mode 100644 src/skills/hosts.ts 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/skills/hosts.ts b/src/skills/hosts.ts new file mode 100644 index 0000000..78290f5 --- /dev/null +++ b/src/skills/hosts.ts @@ -0,0 +1,116 @@ +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}`], +}; + +// 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'], +}; + +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}; From 49304629ce7e2ab60873fd9eb510cf0a2eaeab21 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 14:43:47 -0700 Subject: [PATCH 04/32] feat: detect installed assistants by config dir and binary --- src/__tests__/skills/detect.test.ts | 93 ++++++++++++++++++++ src/skills/detect.ts | 129 ++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 src/__tests__/skills/detect.test.ts create mode 100644 src/skills/detect.ts diff --git a/src/__tests__/skills/detect.test.ts b/src/__tests__/skills/detect.test.ts new file mode 100644 index 0000000..a328f3b --- /dev/null +++ b/src/__tests__/skills/detect.test.ts @@ -0,0 +1,93 @@ +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'); + }); + + 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}); + } + }); +}); diff --git a/src/skills/detect.ts b/src/skills/detect.ts new file mode 100644 index 0000000..6c6ef64 --- /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 = path.dirname(pattern.slice(0, star)); + 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}; From 079837e93fd23e7260594febd2dfbcf87953eefe Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 15:05:19 -0700 Subject: [PATCH 05/32] fix: correct glob_first parent computation and add real-filesystem tests Fix glob_first_real to correctly expand single wildcard segment by stripping trailing separators instead of calling path.dirname, which was walking up an extra level. Add three new tests that exercise the real glob_first implementation against actual filesystem trees with hash-named subdirectories, ensuring Codex off-PATH binary resolution works correctly on Windows. --- src/__tests__/skills/detect.test.ts | 49 +++++++++++++++++++++++++++++ src/skills/detect.ts | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/__tests__/skills/detect.test.ts b/src/__tests__/skills/detect.test.ts index a328f3b..e7c76c3 100644 --- a/src/__tests__/skills/detect.test.ts +++ b/src/__tests__/skills/detect.test.ts @@ -27,6 +27,8 @@ describe('detect_hosts', ()=>{ 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', ()=>{ @@ -90,4 +92,51 @@ describe('default_detect_deps', ()=>{ 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/skills/detect.ts b/src/skills/detect.ts index 6c6ef64..9b6265c 100644 --- a/src/skills/detect.ts +++ b/src/skills/detect.ts @@ -29,7 +29,7 @@ const glob_first_real = (pattern: string): string | undefined=>{ { return fs.existsSync(pattern) ? pattern : undefined; } - const parent = path.dirname(pattern.slice(0, star)); + const parent = pattern.slice(0, star).replace(/[\\/]+$/, ''); const tail = pattern.slice(pattern.indexOf(path.sep, star) + 1); let entries: string[]; try { From 05415f8996c92a997f2589b1e691afccfffaaceb Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 15:11:23 -0700 Subject: [PATCH 06/32] feat: installer journal for flat-directory skill hosts --- src/__tests__/skills/journal.test.ts | 75 +++++++++++++++++++++ src/config.ts | 6 +- src/skills/journal.ts | 97 ++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/skills/journal.test.ts create mode 100644 src/skills/journal.ts diff --git a/src/__tests__/skills/journal.test.ts b/src/__tests__/skills/journal.test.ts new file mode 100644 index 0000000..65b0ead --- /dev/null +++ b/src/__tests__/skills/journal.test.ts @@ -0,0 +1,75 @@ +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, 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', 'ai-sdr-core', env())).toBeUndefined(); + }); + + it('records and reads back an entry', ()=>{ + record_pack('cursor', 'ai-sdr-core', entry(), env()); + expect(journal_entry('cursor', 'ai-sdr-core', env())).toEqual(entry()); + }); + + it('writes the journal next to the other config files', ()=>{ + record_pack('cursor', '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', 'ai-sdr-core', entry('0.1.0'), env()); + record_pack('cursor', 'ai-sdr-core', entry('0.2.0'), env()); + expect(journal_entry('cursor', 'ai-sdr-core', env())?.version).toBe('0.2.0'); + expect(Object.keys(read_journal(env()).hosts.cursor)).toEqual(['ai-sdr-core']); + }); + + it('keeps hosts isolated', ()=>{ + record_pack('cursor', 'ai-sdr-core', entry(), env()); + record_pack('gemini-cli', 'ai-sdr-core', entry('0.9.0'), env()); + expect(journal_entry('cursor', 'ai-sdr-core', env())?.version).toBe('0.1.0'); + expect(journal_entry('gemini-cli', 'ai-sdr-core', env())?.version).toBe('0.9.0'); + }); + + it('forget_pack returns the entry it removed and is idempotent', ()=>{ + record_pack('cursor', 'ai-sdr-core', entry(), env()); + expect(forget_pack('cursor', 'ai-sdr-core', env())?.files).toEqual(['a/SKILL.md']); + expect(forget_pack('cursor', 'ai-sdr-core', env())).toBeUndefined(); + }); + + it('drops the host key once its last pack is forgotten', ()=>{ + record_pack('cursor', 'ai-sdr-core', entry(), env()); + forget_pack('cursor', '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', ()=>{ + fs.writeFileSync(path.join(dir, 'skills.json'), '{ not json'); + expect(()=>read_journal(env())).toThrow(RuntimeError); + }); +}); 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/skills/journal.ts b/src/skills/journal.ts new file mode 100644 index 0000000..3eb1e85 --- /dev/null +++ b/src/skills/journal.ts @@ -0,0 +1,97 @@ +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; + // Absolute paths written by the flat adapter. + files: string[]; + installed_at: string; +}; + +type Journal = { + version: 1; + hosts: Record>; +}; + +const EMPTY: Journal = {version: 1, hosts: {}}; + +const read_journal = (env?: Env): Journal=>{ + const file = skills_file(env); + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch { + return {version: 1, hosts: {}}; + } + if (!raw.trim()) + { + return {version: 1, hosts: {}}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new RuntimeError('The skills journal is corrupt.', { + code: 'skills.journal_corrupt', + detail: file, + hint: 'Delete the file and re-run `reply skills install`.', + }); + } + 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.', { + code: 'skills.journal_corrupt', + detail: file, + hint: 'Delete the file and re-run `reply skills install`.', + }); + } + return {version: 1, hosts: doc.hosts}; +}; + +const write_journal = (journal: Journal, env?: Env): void=>{ + const file = skills_file(env); + fs.mkdirSync(path.dirname(file), {recursive: true, mode: 0o700}); + fs.writeFileSync(file, JSON.stringify(journal, null, 2) + '\n', 'utf8'); +}; + +const journal_entry = (host: string, pack: string, env?: Env): Journal_entry | undefined=> + read_journal(env).hosts[host]?.[pack]; + +const record_pack = (host: string, pack: string, entry: Journal_entry, env?: Env): void=>{ + const journal = read_journal(env); + journal.hosts[host] = {...(journal.hosts[host] ?? {}), [pack]: entry}; + write_journal(journal, env); +}; + +const forget_pack = (host: string, pack: string, env?: Env): Journal_entry | undefined=>{ + const journal = read_journal(env); + const packs = journal.hosts[host]; + const existing = packs?.[pack]; + if (!existing) + { + return undefined; + } + delete packs[pack]; + if (!Object.keys(packs).length) + { + delete journal.hosts[host]; + } + write_journal(journal, env); + return existing; +}; + +export {EMPTY, read_journal, write_journal, journal_entry, record_pack, forget_pack}; +export type {Journal, Journal_entry}; From 0e28c96502cffbce5a4057cdf448581809fac0cf Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 15:18:31 -0700 Subject: [PATCH 07/32] fix: journal error handling and atomic writes per review - Fix Important 1: read_journal now distinguishes ENOENT (no journal) from other read errors (RuntimeError), following file-store.ts pattern - Fix Important 2: write_journal now uses atomic temp-file + rename to prevent data loss on crash, following file-store.ts pattern - Minor: drop dead EMPTY export not in brief's Produces list - Minor: hoist duplicate corrupt-journal error message to constant - Test: add coverage for shape-validation and non-ENOENT read errors --- src/__tests__/skills/journal.test.ts | 14 ++++++++++++- src/skills/journal.ts | 31 +++++++++++++++++++--------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/__tests__/skills/journal.test.ts b/src/__tests__/skills/journal.test.ts index 65b0ead..67876a3 100644 --- a/src/__tests__/skills/journal.test.ts +++ b/src/__tests__/skills/journal.test.ts @@ -68,8 +68,20 @@ describe('skills journal', ()=>{ expect(read_journal(env())).toEqual({version: 1, hosts: {}}); }); - it('throws a RuntimeError on a corrupt journal', ()=>{ + 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/skills/journal.ts b/src/skills/journal.ts index 3eb1e85..31d94da 100644 --- a/src/skills/journal.ts +++ b/src/skills/journal.ts @@ -25,15 +25,23 @@ type Journal = { hosts: Record>; }; -const EMPTY: Journal = {version: 1, hosts: {}}; +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 { - return {version: 1, hosts: {}}; + } 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()) { @@ -43,19 +51,19 @@ const read_journal = (env?: Env): Journal=>{ try { parsed = JSON.parse(raw); } catch { - throw new RuntimeError('The skills journal is corrupt.', { + throw new RuntimeError('The skills journal is corrupt (invalid JSON).', { code: 'skills.journal_corrupt', detail: file, - hint: 'Delete the file and re-run `reply skills install`.', + 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.', { + throw new RuntimeError('The skills journal is corrupt (unexpected shape).', { code: 'skills.journal_corrupt', detail: file, - hint: 'Delete the file and re-run `reply skills install`.', + hint: CORRUPT_HINT, }); } return {version: 1, hosts: doc.hosts}; @@ -63,8 +71,11 @@ const read_journal = (env?: Env): Journal=>{ const write_journal = (journal: Journal, env?: Env): void=>{ const file = skills_file(env); - fs.mkdirSync(path.dirname(file), {recursive: true, mode: 0o700}); - fs.writeFileSync(file, JSON.stringify(journal, null, 2) + '\n', 'utf8'); + 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, pack: string, env?: Env): Journal_entry | undefined=> @@ -93,5 +104,5 @@ const forget_pack = (host: string, pack: string, env?: Env): Journal_entry | und return existing; }; -export {EMPTY, read_journal, write_journal, journal_entry, record_pack, forget_pack}; +export {read_journal, write_journal, journal_entry, record_pack, forget_pack}; export type {Journal, Journal_entry}; From 1eb2bec3436436d81ff0f307d51674a586386122 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 15:28:46 -0700 Subject: [PATCH 08/32] feat: native plugin adapter for Claude Code and Codex --- src/__tests__/skills/adapter-native.test.ts | 171 ++++++++++++++++ src/skills/adapter-native.ts | 210 ++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 src/__tests__/skills/adapter-native.test.ts create mode 100644 src/skills/adapter-native.ts diff --git a/src/__tests__/skills/adapter-native.test.ts b/src/__tests__/skills/adapter-native.test.ts new file mode 100644 index 0000000..ef21547 --- /dev/null +++ b/src/__tests__/skills/adapter-native.test.ts @@ -0,0 +1,171 @@ +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 {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})), +}); + +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'}]))]); + expect(await installed_versions(claude(), run)).toEqual({'ai-sdr-core': '0.1.0'}); + }); + + it('returns an empty map when the host prints nothing usable', async()=>{ + const {run} = runner_of([ok('not json')]); + expect(await installed_versions(claude(), run)).toEqual({}); + }); +}); + +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('ai-sdr-core'); + }); + + 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); + }); +}); + +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[2]).toEqual(['/usr/bin/claude', 'plugin', 'update', 'ai-sdr-core@reply-skills']); + expect(outcome.packs?.map(p=>p.name)).toEqual(['ai-sdr-core']); + }); +}); diff --git a/src/skills/adapter-native.ts b/src/skills/adapter-native.ts new file mode 100644 index 0000000..a39907a --- /dev/null +++ b/src/skills/adapter-native.ts @@ -0,0 +1,210 @@ +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 ?? ''}); + }); +}); + +// Both hosts print a JSON listing, with different envelopes: Claude Code uses +// {plugins:[…]}, Codex uses {installed:[…]}. Anything unparseable means "we +// know nothing", which is safe: the adapter then just installs. +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()); + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + return {}; + } + const doc = (parsed ?? {}) as Record; + const rows = [doc.plugins, doc.installed].find(Array.isArray) as Record[] | undefined; + const out: Record = {}; + for (const row of rows ?? []) + { + const name = row.name; + const version = row.version; + if (typeof name === 'string' && typeof version === 'string') + { + out[name] = version; + } + } + return 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'; +}; + +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 installed = await installed_versions(host, run); + const outcomes: Pack_outcome[] = []; + + if (operation === 'list') + { + 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') + { + // Reverse dependency order: a dependent never outlives its dependency. + for (const pack of [...packs].reverse()) + { + 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)); + outcomes.push(result.code === 0 + ? {name: pack.name, action: 'removed', version: installed[pack.name]} + : {name: pack.name, action: 'failed', detail: (result.stderr || result.stdout).trim()}); + } + return {...base, packs: outcomes, status: status_of(outcomes)}; + } + + if (operation === 'update') + { + for (const pack of packs) + { + const have = installed[pack.name]; + if (!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)); + outcomes.push(result.code === 0 + ? {name: pack.name, action: 'upgraded', version: pack.version, from: have} + : {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. + const failed_names = new Set(); + for (const pack of packs) + { + const blocked = pack.dependencies.find(d=>failed_names.has(d)); + if (blocked) + { + 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}); + } + + const status = status_of(outcomes); + const hint = failed_names.size + ? `packs depending on ${[...failed_names].join(', ')} were not attempted; fix that install and re-run` + : undefined; + return {...base, packs: outcomes, status, hint}; +}; + +export {default_runner, installed_versions, run_native}; +export type {Native_opts}; From 71e0b431a16b60343fd8f2c919f1cd5b5ec1711f Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 15:33:33 -0700 Subject: [PATCH 09/32] fix: pin marketplace registration behavior in update test --- src/__tests__/skills/adapter-native.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/skills/adapter-native.test.ts b/src/__tests__/skills/adapter-native.test.ts index ef21547..3dfae62 100644 --- a/src/__tests__/skills/adapter-native.test.ts +++ b/src/__tests__/skills/adapter-native.test.ts @@ -165,6 +165,7 @@ describe('run_native list and update', ()=>{ 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']); }); From c1a252a588adfe28105c3ca9dfc7e48e368b836b Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 15:42:52 -0700 Subject: [PATCH 10/32] fix: correctness issues in native adapter - Add update_scope to Host_cli to distinguish per-pack vs marketplace-wide updates - Deduplicate Codex's marketplace-wide upgrade (run once, not per pack) - Filter installed plugins by marketplace to prevent silently skipping our packs - Track both failed and blocked packs for transitive dependency chains - Report failed host status when plugin list fails, not success - Re-read listing after updates to report actual versions - Update hint to name blocked packs, only emit when something was actually blocked - Add comprehensive tests for all four fixes --- src/__tests__/skills/adapter-native.test.ts | 91 +++++++++- src/skills/adapter-native.ts | 186 +++++++++++++++++--- src/skills/hosts.ts | 2 + src/skills/types.ts | 3 + 4 files changed, 255 insertions(+), 27 deletions(-) diff --git a/src/__tests__/skills/adapter-native.test.ts b/src/__tests__/skills/adapter-native.test.ts index 3dfae62..6b83388 100644 --- a/src/__tests__/skills/adapter-native.test.ts +++ b/src/__tests__/skills/adapter-native.test.ts @@ -37,12 +37,14 @@ const runner_of = (results: Run_result[]): {run: Runner; calls: string[][]}=>{ 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'}]))]); - expect(await installed_versions(claude(), run)).toEqual({'ai-sdr-core': '0.1.0'}); + const result = await installed_versions(claude(), run); + expect(result).toEqual({ok: true, versions: {'ai-sdr-core': '0.1.0'}}); }); - it('returns an empty map when the host prints nothing usable', async()=>{ + it('returns failure when the host prints nothing usable', async()=>{ const {run} = runner_of([ok('not json')]); - expect(await installed_versions(claude(), run)).toEqual({}); + const result = await installed_versions(claude(), run); + expect(result).toEqual({ok: false}); }); }); @@ -84,7 +86,7 @@ describe('run_native install', ()=>{ 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('ai-sdr-core'); + expect(outcome.hint).toContain('reply-adapter'); }); it('is partial when an independent pack fails but the core succeeded', async()=>{ @@ -169,4 +171,85 @@ describe('run_native list and update', ()=>{ 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']]); + }); + + 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('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/skills/adapter-native.ts b/src/skills/adapter-native.ts index a39907a..9309b56 100644 --- a/src/skills/adapter-native.ts +++ b/src/skills/adapter-native.ts @@ -15,17 +15,26 @@ const default_runner: Runner = (bin, args)=>new Promise(resolve=>{ }); }); +// 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:[…]}. Anything unparseable means "we -// know nothing", which is safe: the adapter then just installs. -const installed_versions = async(host: Detected_host, run: Runner): Promise>=>{ +// {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 {}; + return {ok: false}; } const doc = (parsed ?? {}) as Record; const rows = [doc.plugins, doc.installed].find(Array.isArray) as Record[] | undefined; @@ -34,12 +43,18 @@ const installed_versions = async(host: Detected_host, run: Runner): Promise=>{ } } - const installed = await installed_versions(host, run); + 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]; @@ -121,6 +147,17 @@ const run_native = async(opts: Native_opts): Promise=>{ 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. for (const pack of [...packs].reverse()) { @@ -143,34 +180,137 @@ const run_native = async(opts: Native_opts): Promise=>{ if (operation === 'update') { - for (const pack of packs) + if (!listing.ok) { - const have = installed[pack.name]; - if (!have) + 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) { - continue; + if (dry_run) + { + for (const pack of installed_packs) + { + outcomes.push({name: pack.name, action: 'upgraded', version: pack.version, from: installed[pack.name]}); + } + } + 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 + 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({ + name: pack.name, + action: 'upgraded', + version: post_installed[pack.name] ?? pack.version, + from: have ?? '', + }); + } + } + 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({name: pack.name, action: 'upgraded', version: pack.version, from: have ?? ''}); + } + } + } + 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()}); + } + } + } } - if (dry_run) + } + else + { + // Per-pack updates (Claude Code) + for (const pack of packs) { - outcomes.push({name: pack.name, action: 'upgraded', version: pack.version, from: have}); - continue; + 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 + const post_listing = await installed_versions(host, run); + if (post_listing.ok) + { + outcomes.push({ + name: pack.name, + action: 'upgraded', + version: post_listing.versions[pack.name] ?? pack.version, + from: have, + }); + } + else + { + // Re-read failed; use target version from registry + outcomes.push({name: pack.name, action: 'upgraded', version: pack.version, from: have}); + } + } + else + { + outcomes.push({name: pack.name, action: 'failed', detail: (result.stderr || result.stdout).trim()}); + } } - const result = await run(host.bin, cli.update(pack.name, MARKETPLACE)); - outcomes.push(result.code === 0 - ? {name: pack.name, action: 'upgraded', version: pack.version, from: have} - : {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. + // 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 blocked = pack.dependencies.find(d=>failed_names.has(d)); - if (blocked) + 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]; @@ -200,8 +340,8 @@ const run_native = async(opts: Native_opts): Promise=>{ } const status = status_of(outcomes); - const hint = failed_names.size - ? `packs depending on ${[...failed_names].join(', ')} were not attempted; fix that install and re-run` + const hint = blocked_names.size + ? `packs ${[...blocked_names].join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` : undefined; return {...base, packs: outcomes, status, hint}; }; diff --git a/src/skills/hosts.ts b/src/skills/hosts.ts index 78290f5..e88c44d 100644 --- a/src/skills/hosts.ts +++ b/src/skills/hosts.ts @@ -15,6 +15,7 @@ const claude_cli: Host_cli = { 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. @@ -24,6 +25,7 @@ const codex_cli: Host_cli = { 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[] = [ diff --git a/src/skills/types.ts b/src/skills/types.ts index 4c634c8..f7e51e9 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -26,6 +26,9 @@ type Host_cli = { 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 = { From 9999c3ecf23c03bb93cbd444ed32ef6ef99907dc Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 15:52:32 -0700 Subject: [PATCH 11/32] feat: flat-directory adapter for SKILL.md hosts Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/adapter-flat.test.ts | 164 ++++++++++++++++ src/skills/adapter-flat.ts | 228 ++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 src/__tests__/skills/adapter-flat.test.ts create mode 100644 src/skills/adapter-flat.ts diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts new file mode 100644 index 0000000..10f981d --- /dev/null +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -0,0 +1,164 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {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} from '../../skills/journal'; +import type {Detected_host} from '../../skills/detect'; + +const all = resolve_packs([], PACKS_FALLBACK); +const core_only = resolve_packs(['core'], PACKS_FALLBACK); + +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')}); + +// 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', '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', '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', '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); + }); +}); diff --git a/src/skills/adapter-flat.ts b/src/skills/adapter-flat.ts new file mode 100644 index 0000000..9b3e177 --- /dev/null +++ b/src/skills/adapter-flat.ts @@ -0,0 +1,228 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {default_runner} from './adapter-native'; +import {forget_pack, journal_entry, record_pack} from './journal'; +import {DEFAULT_REF, REPO} from './packs'; +import type {Env} from '../config'; +import type {Detected_host} from './detect'; +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) + { + throw new Error((cloned.stderr || cloned.stdout).trim() || `git clone failed for ${url}`); + } + const head = await run('git', ['-C', dir, 'rev-parse', 'HEAD']); + 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. +const skills_target = (def: Host_def, scope: Scope, home: string, cwd: string): string=> + scope === 'project' + ? path.join(cwd, def.project_skills_dir as string) + : path.join(home, def.user_skills_dir as string); + +const copy_dir = (from: string, to: string): string[]=>{ + const written: string[] = []; + 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()) + { + written.push(...copy_dir(src, dst)); + continue; + } + fs.copyFileSync(src, dst); + written.push(dst); + } + return written; +}; + +// Removes the directories we created, and nothing else: a user-authored skill +// sitting next to ours is never touched because it is not in the journal. +const delete_files = (files: string[]): void=>{ + const dirs = new Set(); + for (const file of files) + { + try { + fs.rmSync(file, {force: true}); + } catch { + // Already gone — removal stays idempotent. + } + dirs.add(path.dirname(file)); + } + for (const dir of [...dirs].sort((a, b)=>b.length - a.length)) + { + try { + if (!fs.readdirSync(dir).length) + { + fs.rmdirSync(dir); + } + } catch { + // Non-empty or missing — leave it alone. + } + } +}; + +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', + }; + const outcomes: Pack_outcome[] = []; + + if (operation === 'list') + { + for (const pack of packs) + { + const entry = journal_entry(id, pack.name, opts.env); + if (!entry) + { + 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}; + } + + if (operation === 'remove') + { + for (const pack of [...packs].reverse()) + { + const entry = journal_entry(id, pack.name, opts.env); + if (!entry) + { + continue; + } + if (!dry_run) + { + delete_files(entry.files); + forget_pack(id, pack.name, opts.env); + } + outcomes.push({name: pack.name, action: 'removed', version: entry.version}); + } + return {...base, packs: outcomes}; + } + + // install and update both need the repository contents. update only touches + // packs the journal already knows about. + const targets = operation === 'update' + ? packs.filter(p=>journal_entry(id, p.name, opts.env)) + : packs; + const pending = targets.filter(p=>{ + const entry = journal_entry(id, p.name, opts.env); + return operation === 'update' || !entry || 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) + { + const entry = journal_entry(id, pack.name, opts.env); + outcomes.push(entry + ? {name: pack.name, action: 'upgraded', version: pack.version, from: entry.version} + : {name: pack.name, action: 'installed', version: pack.version}); + } + 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', + }; + } + + const target_root = skills_target(host.def, scope, home, cwd); + try { + for (const pack of pending) + { + const from = path.join(cloned.dir, 'plugins', pack.name, 'skills'); + const previous = journal_entry(id, pack.name, opts.env); + if (previous) + { + delete_files(previous.files); + } + const written: string[] = []; + for (const skill of fs.readdirSync(from, {withFileTypes: true})) + { + if (skill.isDirectory()) + { + written.push(...copy_dir(path.join(from, skill.name), path.join(target_root, skill.name))); + } + } + record_pack(id, pack.name, { + version: pack.version, + ref, + commit: cloned.commit, + scope, + files: written, + installed_at: new Date().toISOString(), + }, opts.env); + outcomes.push(previous + ? {name: pack.name, action: 'upgraded', version: pack.version, from: previous.version} + : {name: pack.name, action: 'installed', version: pack.version}); + } + } finally { + fs.rmSync(cloned.dir, {recursive: true, force: true}); + } + return {...base, packs: outcomes}; +}; + +export {clone_repo, skills_target, run_flat}; +export type {Clone_fn, Clone_result, Flat_opts}; From 58f0af2e3a4ce810f9c7dbead62ce532254f2512 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 16:17:46 -0700 Subject: [PATCH 12/32] fix: scope-aware skills journal and safer flat-adapter deletion/copy Reviewer found the journal had no scope in its key, so a user-scope install and a --project install of the same pack on the same host could delete each other's files or silently no-op; the copy/journal loop could reject instead of returning a failed Host_outcome; deletion had no containment against a tampered journal entry; a same-named user skill could be silently overwritten and later deleted; several flat hosts sharing .agents/skills under --project could delete each other's install; and skills_target could crash on a host with no directory for the requested scope. Fixes all six, plus two related one-liners in clone_repo (check git rev-parse's exit code, clean up its temp dir on either failure path). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/adapter-flat.test.ts | 215 +++++++++++++++++++++- src/__tests__/skills/journal.test.ts | 44 +++-- src/skills/adapter-flat.ts | 209 +++++++++++++++++---- src/skills/journal.ts | 26 ++- 4 files changed, 433 insertions(+), 61 deletions(-) diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts index 10f981d..4f3430c 100644 --- a/src/__tests__/skills/adapter-flat.test.ts +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -2,11 +2,12 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import {run_flat, skills_target} from '../../skills/adapter-flat'; +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} from '../../skills/journal'; +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); @@ -19,6 +20,17 @@ 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) @@ -87,7 +99,7 @@ describe('run_flat install', ()=>{ 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', 'ai-sdr-core', env()); + 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'); @@ -117,7 +129,7 @@ describe('run_flat install', ()=>{ 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', 'ai-sdr-core', env())).toBeUndefined(); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); }); it('fails the host with an actionable hint when cloning fails', async()=>{ @@ -137,7 +149,7 @@ describe('run_flat remove and list', ()=>{ 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', 'ai-sdr-core', env())).toBeUndefined(); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'removed', version: '0.1.0'}]); }); @@ -162,3 +174,196 @@ describe('run_flat remove and list', ()=>{ 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('does not delete a journaled path outside the skills directory', 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], 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); + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'removed', version: '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], 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(fs.readFileSync(mine)).toEqual(before); + 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); + }); +}); diff --git a/src/__tests__/skills/journal.test.ts b/src/__tests__/skills/journal.test.ts index 67876a3..870caa0 100644 --- a/src/__tests__/skills/journal.test.ts +++ b/src/__tests__/skills/journal.test.ts @@ -23,43 +23,53 @@ afterEach(()=>{ describe('skills journal', ()=>{ it('reads an empty journal before anything is written', ()=>{ expect(read_journal(env())).toEqual({version: 1, hosts: {}}); - expect(journal_entry('cursor', 'ai-sdr-core', env())).toBeUndefined(); + expect(journal_entry('cursor', 'user', 'ai-sdr-core', env())).toBeUndefined(); }); it('records and reads back an entry', ()=>{ - record_pack('cursor', 'ai-sdr-core', entry(), env()); - expect(journal_entry('cursor', 'ai-sdr-core', env())).toEqual(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', 'ai-sdr-core', entry(), env()); + 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', 'ai-sdr-core', entry('0.1.0'), env()); - record_pack('cursor', 'ai-sdr-core', entry('0.2.0'), env()); - expect(journal_entry('cursor', 'ai-sdr-core', env())?.version).toBe('0.2.0'); - expect(Object.keys(read_journal(env()).hosts.cursor)).toEqual(['ai-sdr-core']); + 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', 'ai-sdr-core', entry(), env()); - record_pack('gemini-cli', 'ai-sdr-core', entry('0.9.0'), env()); - expect(journal_entry('cursor', 'ai-sdr-core', env())?.version).toBe('0.1.0'); - expect(journal_entry('gemini-cli', 'ai-sdr-core', env())?.version).toBe('0.9.0'); + 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', 'ai-sdr-core', entry(), env()); - expect(forget_pack('cursor', 'ai-sdr-core', env())?.files).toEqual(['a/SKILL.md']); - expect(forget_pack('cursor', 'ai-sdr-core', env())).toBeUndefined(); + 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', 'ai-sdr-core', entry(), env()); - forget_pack('cursor', 'ai-sdr-core', env()); + record_pack('cursor', 'user', 'ai-sdr-core', entry(), env()); + forget_pack('cursor', 'user', 'ai-sdr-core', env()); expect(read_journal(env()).hosts.cursor).toBeUndefined(); }); diff --git a/src/skills/adapter-flat.ts b/src/skills/adapter-flat.ts index 9b3e177..95aed43 100644 --- a/src/skills/adapter-flat.ts +++ b/src/skills/adapter-flat.ts @@ -2,10 +2,11 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import {default_runner} from './adapter-native'; -import {forget_pack, journal_entry, record_pack} from './journal'; +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 @@ -21,21 +22,33 @@ const clone_repo: Clone_fn = async({ref, run, tmp_root})=>{ 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. -const skills_target = (def: Host_def, scope: Scope, home: string, cwd: string): string=> - scope === 'project' - ? path.join(cwd, def.project_skills_dir as string) - : path.join(home, def.user_skills_dir as string); +// 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); +}; -const copy_dir = (from: string, to: string): string[]=>{ - const written: string[] = []; +// 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})) { @@ -43,30 +56,51 @@ const copy_dir = (from: string, to: string): string[]=>{ const dst = path.join(to, entry.name); if (entry.isDirectory()) { - written.push(...copy_dir(src, dst)); + copy_dir(src, dst, written); continue; } fs.copyFileSync(src, dst); written.push(dst); } - return written; }; -// Removes the directories we created, and nothing else: a user-authored skill -// sitting next to ours is never touched because it is not in the journal. -const delete_files = (files: string[]): void=>{ +// 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. +const is_within = (root: string, target: string): boolean=>{ + const rel = path.relative(root, target); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +}; + +// 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()): void=>{ + const resolved_root = path.resolve(target_root); const dirs = new Set(); for (const file of files) { + const resolved = path.resolve(file); + if (!is_within(resolved_root, resolved) || protected_files.has(resolved)) + { + continue; + } try { - fs.rmSync(file, {force: true}); + fs.rmSync(resolved, {force: true}); } catch { // Already gone — removal stays idempotent. } - dirs.add(path.dirname(file)); + 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) { @@ -78,6 +112,48 @@ const delete_files = (files: string[]): void=>{ } }; +// 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. +const claimed_by_others = (env: Env | undefined, scope: Scope, pack_name: string, exclude_host: 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; + } + 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. +const owns_dir = (dir: string, known_files: Iterable): boolean=>{ + const prefix = path.resolve(dir) + path.sep; + for (const file of known_files) + { + if (file.startsWith(prefix)) + { + return true; + } + } + return false; +}; + type Flat_opts = { operation: Operation; host: Detected_host; @@ -106,13 +182,21 @@ const run_flat = async(opts: Flat_opts): Promise=>{ const base: Host_outcome = { host: id, label: host.def.label, kind: 'flat-skills-dir', scope, status: 'ok', }; + // Scope-bound wrappers: every journal lookup for this run goes through + // these, so `scope` can never be forgotten at a call site. + const entry_for = (pack_name: string): Journal_entry | undefined=> + journal_entry(id, scope, pack_name, opts.env); + const record_for = (pack_name: string, data: Journal_entry): void=> + record_pack(id, scope, pack_name, data, opts.env); + const forget_for = (pack_name: string): Journal_entry | undefined=> + forget_pack(id, scope, pack_name, opts.env); const outcomes: Pack_outcome[] = []; if (operation === 'list') { for (const pack of packs) { - const entry = journal_entry(id, pack.name, opts.env); + const entry = entry_for(pack.name); if (!entry) { continue; @@ -124,19 +208,34 @@ const run_flat = async(opts: Flat_opts): Promise=>{ return {...base, packs: 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') { for (const pack of [...packs].reverse()) { - const entry = journal_entry(id, pack.name, opts.env); + const entry = entry_for(pack.name); if (!entry) { continue; } if (!dry_run) { - delete_files(entry.files); - forget_pack(id, pack.name, opts.env); + const protected_files = claimed_by_others(opts.env, scope, pack.name, id); + delete_files(entry.files, target_root, protected_files); + forget_for(pack.name); } outcomes.push({name: pack.name, action: 'removed', version: entry.version}); } @@ -146,10 +245,10 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // install and update both need the repository contents. update only touches // packs the journal already knows about. const targets = operation === 'update' - ? packs.filter(p=>journal_entry(id, p.name, opts.env)) + ? packs.filter(p=>entry_for(p.name)) : packs; const pending = targets.filter(p=>{ - const entry = journal_entry(id, p.name, opts.env); + const entry = entry_for(p.name); return operation === 'update' || !entry || entry.version !== p.version; }); for (const pack of targets) @@ -167,7 +266,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ { for (const pack of pending) { - const entry = journal_entry(id, pack.name, opts.env); + const entry = entry_for(pack.name); outcomes.push(entry ? {name: pack.name, action: 'upgraded', version: pack.version, from: entry.version} : {name: pack.name, action: 'installed', version: pack.version}); @@ -188,41 +287,87 @@ const run_flat = async(opts: Flat_opts): Promise=>{ }; } - const target_root = skills_target(host.def, scope, home, cwd); + // 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. try { for (const pack of pending) { const from = path.join(cloned.dir, 'plugins', pack.name, 'skills'); - const previous = journal_entry(id, pack.name, opts.env); + const previous = entry_for(pack.name); + const elsewhere = claimed_by_others(opts.env, scope, pack.name, id); + 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) + { + outcomes.push({ + name: pack.name, + action: 'failed', + detail: `conflicts with an existing skill: ${collision.name}`, + }); + continue; + } if (previous) { - delete_files(previous.files); + delete_files(previous.files, target_root, elsewhere); } const written: string[] = []; - for (const skill of fs.readdirSync(from, {withFileTypes: true})) - { - if (skill.isDirectory()) + 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 is still on disk: journal + // it so a later `remove` can clean it up instead of leaving + // it as an orphan the journal never knew about. + if (written.length) { - written.push(...copy_dir(path.join(from, skill.name), path.join(target_root, skill.name))); + record_for(pack.name, { + version: pack.version, ref, commit: cloned.commit, scope, + files: written, installed_at: new Date().toISOString(), + }); } + throw copy_error; } - record_pack(id, pack.name, { + record_for(pack.name, { version: pack.version, ref, commit: cloned.commit, scope, files: written, installed_at: new Date().toISOString(), - }, opts.env); + }); outcomes.push(previous ? {name: pack.name, action: 'upgraded', version: pack.version, from: previous.version} : {name: pack.name, action: 'installed', version: pack.version}); } + } catch (error) { + return { + ...base, + status: outcomes.length ? 'partial' : 'failed', + packs: outcomes, + reason: 'copy-failed', + detail: (error as Error).message, + hint: 'check filesystem permissions for the skills directory, then re-run', + }; } finally { - fs.rmSync(cloned.dir, {recursive: true, force: true}); + 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 {...base, packs: outcomes}; }; -export {clone_repo, skills_target, run_flat}; +export {clone_repo, copy_dir, skills_target, run_flat}; export type {Clone_fn, Clone_result, Flat_opts}; diff --git a/src/skills/journal.ts b/src/skills/journal.ts index 31d94da..e8cdac1 100644 --- a/src/skills/journal.ts +++ b/src/skills/journal.ts @@ -20,9 +20,14 @@ type Journal_entry = { 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>; + hosts: Record>>; }; const CORRUPT_HINT = 'Delete the file and re-run `reply skills install`.'; @@ -78,18 +83,21 @@ const write_journal = (journal: Journal, env?: Env): void=>{ fs.renameSync(tmp, file); }; -const journal_entry = (host: string, pack: string, env?: Env): Journal_entry | undefined=> - read_journal(env).hosts[host]?.[pack]; +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, pack: string, entry: Journal_entry, env?: Env): void=>{ +const record_pack = (host: string, scope: Scope, pack: string, entry: Journal_entry, env?: Env): void=>{ const journal = read_journal(env); - journal.hosts[host] = {...(journal.hosts[host] ?? {}), [pack]: entry}; + const scopes = journal.hosts[host] ?? {}; + scopes[scope] = {...(scopes[scope] ?? {}), [pack]: entry}; + journal.hosts[host] = scopes; write_journal(journal, env); }; -const forget_pack = (host: string, pack: string, env?: Env): Journal_entry | undefined=>{ +const forget_pack = (host: string, scope: Scope, pack: string, env?: Env): Journal_entry | undefined=>{ const journal = read_journal(env); - const packs = journal.hosts[host]; + const scopes = journal.hosts[host]; + const packs = scopes?.[scope]; const existing = packs?.[pack]; if (!existing) { @@ -97,6 +105,10 @@ const forget_pack = (host: string, pack: string, env?: Env): Journal_entry | und } delete packs[pack]; if (!Object.keys(packs).length) + { + delete scopes![scope]; + } + if (scopes && !Object.keys(scopes).length) { delete journal.hosts[host]; } From 83095ac92d32e9faa3a353945b208709791c383f Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 16:25:18 -0700 Subject: [PATCH 13/32] fix: block dependent packs when a flat-host install fails adapter-native.ts already refuses to install a dependent (reply-adapter, agentic-runtime) once its dependency (ai-sdr-core) has failed, via failed_names/blocked_names. The flat adapter had no equivalent: a pack that failed on a name collision just continued to the next pack, so a dependent could still be copied and journaled with its dependency missing. Mirrors the native adapter's tracking and hint, and makes the host-level status reflect a collision failure instead of staying 'ok'. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/adapter-flat.test.ts | 37 +++++++++++++++++++++++ src/skills/adapter-flat.ts | 32 +++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts index 4f3430c..7cc112d 100644 --- a/src/__tests__/skills/adapter-flat.test.ts +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -289,8 +289,45 @@ describe('run_flat name collisions', ()=>{ 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(); }); }); diff --git a/src/skills/adapter-flat.ts b/src/skills/adapter-flat.ts index 95aed43..9664095 100644 --- a/src/skills/adapter-flat.ts +++ b/src/skills/adapter-flat.ts @@ -154,6 +154,19 @@ const owns_dir = (dir: string, known_files: Iterable): boolean=>{ 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'; +}; + type Flat_opts = { operation: Operation; host: Detected_host; @@ -291,9 +304,22 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // 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 = claimed_by_others(opts.env, scope, pack.name, id); @@ -307,6 +333,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ }); if (collision) { + failed_names.add(pack.name); outcomes.push({ name: pack.name, action: 'failed', @@ -366,7 +393,10 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // the result computed above. } } - return {...base, packs: outcomes}; + const hint = blocked_names.size + ? `packs ${[...blocked_names].join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` + : undefined; + return {...base, packs: outcomes, status: status_of(outcomes), hint}; }; export {clone_repo, copy_dir, skills_target, run_flat}; From 9fdffe4d1c6a8efd4a6308391c9e4da4fa2cfac5 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 16:51:08 -0700 Subject: [PATCH 14/32] fix: mark incomplete flat-adapter installs so repair actually re-runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed copy journaled the partial file list under the target version, so `pending` (and `list`) read it as installed and `install` silently did nothing on retry — the exact repair the failure hint tells the user to run. Journal_entry gains `complete: boolean`; a version match alone no longer counts as installed, and an incomplete entry is always re-attempted and never reported `current`. Also folds in two related fixes flagged by review: the copy-error abort path used outcomes.length as a stand-in for "something landed" (wrong when every outcome was itself a failure) and dropped the blocked-packs hint; and owns_dir/protected_files compared paths case-sensitively while is_within did not, which could misread a differently-cased path (routine on Windows) as a foreign collision. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/adapter-flat.test.ts | 143 +++++++++++++++++++++- src/__tests__/skills/journal.test.ts | 2 +- src/skills/adapter-flat.ts | 67 ++++++---- src/skills/journal.ts | 6 + 4 files changed, 192 insertions(+), 26 deletions(-) diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts index 7cc112d..bcf8fc6 100644 --- a/src/__tests__/skills/adapter-flat.test.ts +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -11,6 +11,11 @@ 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; @@ -252,7 +257,7 @@ describe('run_flat deletion containment', ()=>{ 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], installed_at: '2026-07-30T00:00:00.000Z', + 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); @@ -266,7 +271,7 @@ describe('run_flat deletion containment', ()=>{ fs.writeFileSync(direct, 'x'); record_pack('cursor', 'user', 'ai-sdr-core', { version: '0.1.0', ref: 'main', commit: 'deadbee', scope: 'user', - files: [direct], installed_at: '2026-07-30T00:00:00.000Z', + 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); @@ -404,3 +409,135 @@ describe('clone_repo', ()=>{ 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('re-attempts a pack whose entry is marked incomplete, even at the target version, instead of reporting current', 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)); + + expect(outcome.packs?.map(p=>({name: p.name, action: p.action}))).toEqual([ + {name: 'ai-sdr-core', action: 'upgraded'}, + ]); + 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); + }); + + 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'); + }); +}); + +// 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(); + } + }); +}); + +// Minor, folded in for the same reason: owns_dir/protected_files compared +// paths case-sensitively while is_within does not, so a differently-cased +// path — routine on Windows, which CI runs — was recognised by one and not +// the other. Both must agree. +describe('run_flat case-insensitive path comparisons', ()=>{ + it('recognises a differently-cased journaled path as already ours, not a foreign collision', async()=>{ + const target = path.join(home, '.cursor', 'skills'); + // Same physical file as fake_clone will produce, but recorded with + // different case — as a differently-cased-but-equivalent path from a + // prior run might be, on a case-insensitive filesystem. + 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'); + + // `update`, not `install`: the entry is already complete at the + // target version, so `install` would report `current` without ever + // reaching the collision/ownership check this test exercises. + const outcome = await run_flat(flat_opts('update', core_only)); + + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'upgraded', version: '0.1.0', from: '0.1.0'}]); + expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + }); +}); diff --git a/src/__tests__/skills/journal.test.ts b/src/__tests__/skills/journal.test.ts index 870caa0..f5cba5b 100644 --- a/src/__tests__/skills/journal.test.ts +++ b/src/__tests__/skills/journal.test.ts @@ -10,7 +10,7 @@ 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, installed_at: '2026-07-30T00:00:00.000Z', + version, ref: 'main', commit: 'abc1234', scope: 'user', files, complete: true, installed_at: '2026-07-30T00:00:00.000Z', }); beforeEach(()=>{ diff --git a/src/skills/adapter-flat.ts b/src/skills/adapter-flat.ts index 9664095..abf5b37 100644 --- a/src/skills/adapter-flat.ts +++ b/src/skills/adapter-flat.ts @@ -68,11 +68,18 @@ const copy_dir = (from: string, to: string, written: string[]): void=>{ // 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) === ''; + // 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 @@ -84,7 +91,7 @@ const delete_files = (files: string[], target_root: string, protected_files: Set for (const file of files) { const resolved = path.resolve(file); - if (!is_within(resolved_root, resolved) || protected_files.has(resolved)) + if (!is_within(resolved_root, resolved) || [...protected_files].some(p=>paths_equal(p, resolved))) { continue; } @@ -141,12 +148,13 @@ const claimed_by_others = (env: Env | undefined, scope: Scope, pack_name: string // 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. +// 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=>{ - const prefix = path.resolve(dir) + path.sep; for (const file of known_files) { - if (file.startsWith(prefix)) + if (is_within(dir, file)) { return true; } @@ -214,11 +222,23 @@ const run_flat = async(opts: Flat_opts): Promise=>{ { 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}; + return {...base, packs: outcomes, status: status_of(outcomes)}; } // Every other operation touches the filesystem, so a host with no @@ -262,7 +282,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ : packs; const pending = targets.filter(p=>{ const entry = entry_for(p.name); - return operation === 'update' || !entry || entry.version !== p.version; + return operation === 'update' || !entry || !entry.complete || entry.version !== p.version; }); for (const pack of targets) { @@ -311,6 +331,10 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // way regardless of which adapter is doing the installing. const failed_names = new Set(); const blocked_names = new Set(); + const blocked_hint = (): string | undefined=> + blocked_names.size + ? `packs ${[...blocked_names].join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` + : undefined; try { for (const pack of pending) { @@ -352,16 +376,14 @@ const run_flat = async(opts: Flat_opts): Promise=>{ copy_dir(path.join(from, skill.name), path.join(target_root, skill.name), written); } } catch (copy_error) { - // Whatever landed before the failure is still on disk: journal - // it so a later `remove` can clean it up instead of leaving - // it as an orphan the journal never knew about. - if (written.length) - { - record_for(pack.name, { - version: pack.version, ref, commit: cloned.commit, scope, - files: written, installed_at: new Date().toISOString(), - }); - } + // 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, { @@ -370,6 +392,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ commit: cloned.commit, scope, files: written, + complete: true, installed_at: new Date().toISOString(), }); outcomes.push(previous @@ -377,13 +400,16 @@ const run_flat = async(opts: Flat_opts): Promise=>{ : {name: pack.name, action: 'installed', version: pack.version}); } } 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 { ...base, - status: outcomes.length ? 'partial' : 'failed', + status: landed ? 'partial' : 'failed', packs: outcomes, reason: 'copy-failed', detail: (error as Error).message, - hint: 'check filesystem permissions for the skills directory, then re-run', + hint: blocked_hint() ?? 'check filesystem permissions for the skills directory, then re-run', }; } finally { try { @@ -393,10 +419,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // the result computed above. } } - const hint = blocked_names.size - ? `packs ${[...blocked_names].join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` - : undefined; - return {...base, packs: outcomes, status: status_of(outcomes), hint}; + return {...base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint()}; }; export {clone_repo, copy_dir, skills_target, run_flat}; diff --git a/src/skills/journal.ts b/src/skills/journal.ts index e8cdac1..4d084a4 100644 --- a/src/skills/journal.ts +++ b/src/skills/journal.ts @@ -17,6 +17,12 @@ type Journal_entry = { scope: Scope; // 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; }; From 3a361674203929272126d22254cb158ea2b83438 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 17:00:49 -0700 Subject: [PATCH 15/32] feat: per-host reporting and exit-code mapping for skills Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/report.test.ts | 115 ++++++++++++++++++++++++++++ src/skills/report.ts | 102 ++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/__tests__/skills/report.test.ts create mode 100644 src/skills/report.ts diff --git a/src/__tests__/skills/report.test.ts b/src/__tests__/skills/report.test.ts new file mode 100644 index 0000000..64a6b76 --- /dev/null +++ b/src/__tests__/skills/report.test.ts @@ -0,0 +1,115 @@ +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=>({ + action: 'install', + source: {repo: 'reply-team/reply-skills', ref: 'main'}, + requested: ['ai-sdr-core', 'reply-adapter'], + resolved: ['ai-sdr-core', 'reply-adapter'], + hosts, + summary: summarize(hosts), + ...over, +}); + +describe('summarize', ()=>{ + it('counts hosts by outcome, not packs', ()=>{ + expect(summarize([host(), host({host: 'codex', status: 'skipped'}), host({host: 'x', status: 'failed'})])) + .toEqual({installed: 1, skipped: 1, failed: 1}); + }); + + it('counts a partial host as installed, because something landed', ()=>{ + expect(summarize([host({status: 'partial'})])).toEqual({installed: 1, 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 out = text(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'}, + ], + })])); + expect(out).toContain('installation incomplete; run `reply skills install` to repair'); + }); + + 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); + }); +}); + +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/skills/report.ts b/src/skills/report.ts new file mode 100644 index 0000000..5d6cc38 --- /dev/null +++ b/src/skills/report.ts @@ -0,0 +1,102 @@ +import {pc} from '../utils/output'; +import type {Host_outcome, 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. + +const summarize = (hosts: Host_outcome[]): Report['summary']=>({ + installed: 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; + +// Groups a host's packs by what happened, so one host is one line. +const host_line = (host: Host_outcome): 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 = verb[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('⚠'); + return `${mark} ${label}· ${parts.join('; ')}`; +}; + +const changed = (report: Report): boolean=>report.hosts.some(h=> + (h.packs ?? []).some(p=>p.action === 'installed' || p.action === 'upgraded')); + +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)); + 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.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}; From d70880c6ace782f8d7adcb4dd0fd3584f2d1de97 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 17:09:23 -0700 Subject: [PATCH 16/32] fix: surface pack names in failed-pack detail lines and improve test specificity - Label detail lines with pack name (e.g. 'reply-adapter: installation incomplete...') so multiple failed packs on one host are unambiguous - Replace detail-test substring assertion with line-array check: asserts detail appears as its own indented line with pack name - Add test with two failed packs having different details, asserting both appear with their correct pack labels on distinct lines Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/report.test.ts | 19 ++++++++++++++++--- src/skills/report.ts | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/__tests__/skills/report.test.ts b/src/__tests__/skills/report.test.ts index 64a6b76..10f9e87 100644 --- a/src/__tests__/skills/report.test.ts +++ b/src/__tests__/skills/report.test.ts @@ -67,13 +67,26 @@ describe('human_lines', ()=>{ }); it('surfaces a pack-level detail when the pack action is failed', ()=>{ - const out = text(report([host({ + 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'}, ], - })])); - expect(out).toContain('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); }); it('reports a pulled dependency once', ()=>{ diff --git a/src/skills/report.ts b/src/skills/report.ts index 5d6cc38..c236fc3 100644 --- a/src/skills/report.ts +++ b/src/skills/report.ts @@ -79,7 +79,7 @@ const human_lines = (report: Report): string[]=>{ const failed_packs = (host.packs ?? []).filter(p=>p.action === 'failed' && p.detail); for (const pack of failed_packs) { - lines.push(pc.dim(` ${pack.detail}`)); + lines.push(pc.dim(` ${pack.name}: ${pack.detail}`)); } } if (changed(report) && report.action !== 'list') From 9abfe4df678f8d220597a2fd27673db5da7a0989 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 17:16:41 -0700 Subject: [PATCH 17/32] feat: orchestrate skills install across detected assistants --- src/__tests__/skills/orchestrate.test.ts | 180 +++++++++++++++++++++++ src/skills/orchestrate.ts | 151 +++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 src/__tests__/skills/orchestrate.test.ts create mode 100644 src/skills/orchestrate.ts diff --git a/src/__tests__/skills/orchestrate.test.ts b/src/__tests__/skills/orchestrate.test.ts new file mode 100644 index 0000000..5650bf7 --- /dev/null +++ b/src/__tests__/skills/orchestrate.test.ts @@ -0,0 +1,180 @@ +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 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('routes a native host through the flat adapter under --project', 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); + }); +}); diff --git a/src/skills/orchestrate.ts b/src/skills/orchestrate.ts new file mode 100644 index 0000000..199355f --- /dev/null +++ b/src/skills/orchestrate.ts @@ -0,0 +1,151 @@ +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 {journal_entry} from './journal'; +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_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 = (id: string, label: string, kind: Host_outcome['kind']): Host_outcome=>({ + host: id, label, kind, status: 'skipped', reason: 'not-detected', + detail: `${label} was requested with --agent but is not installed on this machine`, +}); + +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[] = []; + let commit: string | undefined; + + 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'); + const 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, + }); + hosts.push(outcome); + } + for (const def of missing) + { + hosts.push(not_detected(def.id, def.label, def.kind)); + } + + // The commit is only known when something was cloned; native installs are + // resolved by the host, which reports versions, not commits. + const flat_used = hosts.some(h=>h.kind === 'flat-skills-dir' && h.status !== 'skipped'); + if (flat_used && !opts.dry_run) + { + for (const pack of packs) + { + for (const host of hosts) + { + const entry = journal_entry(host.host, scope, pack.name, deps.env); + if (entry?.commit) + { + commit = entry.commit; + } + } + } + } + + // `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), + }; +}; + +export {guard_remove, run_skills}; +export type {Skills_deps, Skills_opts}; From 4093eaad5af8b2edba09fd6243f10f4ab56c4daa Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 17:28:47 -0700 Subject: [PATCH 18/32] fix: isolate per-host adapter failures and stop misattributing install commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap each per-host adapter call in the orchestrator so an escaping throw (e.g. a journal write outside run_flat's own try/catch) becomes that host's failed outcome instead of aborting every other host's run. Stop deriving source.commit by scanning the journal after the fact: a flat host now stamps its own Host_outcome.commit with the commit it actually cloned this run, and the orchestrator reports it only when every flat host that cloned agrees — never a stale entry from an earlier run, a sibling host's commit, or a commit from a run whose clone failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/orchestrate.test.ts | 42 +++++++++++++++ src/skills/adapter-flat.ts | 8 ++- src/skills/orchestrate.ts | 67 +++++++++++++----------- src/skills/types.ts | 7 +++ 4 files changed, 92 insertions(+), 32 deletions(-) diff --git a/src/__tests__/skills/orchestrate.test.ts b/src/__tests__/skills/orchestrate.test.ts index 5650bf7..97c2195 100644 --- a/src/__tests__/skills/orchestrate.test.ts +++ b/src/__tests__/skills/orchestrate.test.ts @@ -177,4 +177,46 @@ describe('run_skills', ()=>{ 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'}); + }); + + 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/skills/adapter-flat.ts b/src/skills/adapter-flat.ts index abf5b37..568b553 100644 --- a/src/skills/adapter-flat.ts +++ b/src/skills/adapter-flat.ts @@ -319,6 +319,10 @@ const run_flat = async(opts: Flat_opts): Promise=>{ 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 @@ -404,7 +408,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // far could itself be a collision failure, so check the actions. const landed = outcomes.some(p=>p.action !== 'failed'); return { - ...base, + ...cloned_base, status: landed ? 'partial' : 'failed', packs: outcomes, reason: 'copy-failed', @@ -419,7 +423,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // the result computed above. } } - return {...base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint()}; + return {...cloned_base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint()}; }; export {clone_repo, copy_dir, skills_target, run_flat}; diff --git a/src/skills/orchestrate.ts b/src/skills/orchestrate.ts index 199355f..15091fc 100644 --- a/src/skills/orchestrate.ts +++ b/src/skills/orchestrate.ts @@ -2,7 +2,6 @@ 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 {journal_entry} from './journal'; import {DEFAULT_REF, REPO, load_packs, resolve_packs} from './packs'; import {summarize} from './report'; import {UsageError} from '../utils/errors'; @@ -86,7 +85,6 @@ const run_skills = async(opts: Skills_opts): Promise=>{ const {selected, missing} = select_hosts(opts.agents, detect); const hosts: Host_outcome[] = []; - let commit: string | undefined; for (const host of selected) { @@ -95,17 +93,33 @@ const run_skills = async(opts: Skills_opts): Promise=>{ // can, Codex cannot. const native = host.def.kind === 'native-plugin' && !(scope === 'project' && host.def.id === 'codex'); - const 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, - }); + 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`, + }; + } hosts.push(outcome); } for (const def of missing) @@ -113,23 +127,16 @@ const run_skills = async(opts: Skills_opts): Promise=>{ hosts.push(not_detected(def.id, def.label, def.kind)); } - // The commit is only known when something was cloned; native installs are - // resolved by the host, which reports versions, not commits. - const flat_used = hosts.some(h=>h.kind === 'flat-skills-dir' && h.status !== 'skipped'); - if (flat_used && !opts.dry_run) - { - for (const pack of packs) - { - for (const host of hosts) - { - const entry = journal_entry(host.host, scope, pack.name, deps.env); - if (entry?.commit) - { - commit = entry.commit; - } - } - } - } + // 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 diff --git a/src/skills/types.ts b/src/skills/types.ts index f7e51e9..647cd74 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -72,6 +72,13 @@ type Host_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; }; type Operation = 'install' | 'list' | 'update' | 'remove'; From c2e6a2b73a8f64518d6c43988f867f3dd885be7d Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 17:39:27 -0700 Subject: [PATCH 19/32] feat: add reply skills install/list/update/remove --- README.md | 50 ++++++++++ src/__tests__/commands/skills.test.ts | 125 ++++++++++++++++++++++++ src/commands/skills.ts | 131 ++++++++++++++++++++++++++ src/index.ts | 4 + 4 files changed, 310 insertions(+) create mode 100644 src/__tests__/commands/skills.test.ts create mode 100644 src/commands/skills.ts diff --git a/README.md b/README.md index e3fd4a7..154c124 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,56 @@ 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 ('*' marks an available update) +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. + +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/src/__tests__/commands/skills.test.ts b/src/__tests__/commands/skills.test.ts new file mode 100644 index 0000000..8b92f21 --- /dev/null +++ b/src/__tests__/commands/skills.test.ts @@ -0,0 +1,125 @@ +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', ()=>{ + it('prints the human summary 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 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}); + }); + + 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/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..9a4c830 --- /dev/null +++ b/src/commands/skills.ts @@ -0,0 +1,131 @@ +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}); + +// 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 + { + for (const line of human_lines(report)) + { + console.error(line); + } + } + + // The report is printed either way: exiting non-zero without it would hide + // why each host failed. + if (exit_code_for(report) !== 0) + { + throw new RuntimeError('No assistant received the skills.', { + code: 'skills.nothing_installed', + hint: `run \`${PROGRAM_NAME} skills install --dry-run\` 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/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; From e4f809bcc34a1430fd9d826195726994a381d5f6 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 17:50:17 -0700 Subject: [PATCH 20/32] fix: distinguish available-update from applied-update in skills list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reply skills list is read-only, but reused the "upgraded" pack action to mean "a newer version exists" — which rendered as " updated" and falsely implied the read-only listing had changed something. human_lines now renders that case as "update available" only when the report action is list, leaving install/update wording unchanged, and the README no longer claims a '*' marker that nothing in the code ever prints. --- README.md | 2 +- src/__tests__/skills/report.test.ts | 15 +++++++++++++++ src/skills/report.ts | 13 +++++++++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 154c124..8072c10 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ reply skills install --project # into this repository, not your home Then manage them: ```sh -reply skills list # what's installed where ('*' marks an available update) +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 diff --git a/src/__tests__/skills/report.test.ts b/src/__tests__/skills/report.test.ts index 10f9e87..57774b3 100644 --- a/src/__tests__/skills/report.test.ts +++ b/src/__tests__/skills/report.test.ts @@ -103,6 +103,21 @@ describe('human_lines', ()=>{ const out = text(report([host({packs: [{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]})])); expect(out).not.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', ()=>{ diff --git a/src/skills/report.ts b/src/skills/report.ts index c236fc3..50ac1d9 100644 --- a/src/skills/report.ts +++ b/src/skills/report.ts @@ -1,5 +1,5 @@ import {pc} from '../utils/output'; -import type {Host_outcome, Report} from './types'; +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 @@ -25,8 +25,13 @@ const verb = { 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): string=>{ +const host_line = (host: Host_outcome, report_action: Operation): string=>{ const label = host.label.padEnd(12); if (host.status === 'skipped') { @@ -39,7 +44,7 @@ const host_line = (host: Host_outcome): string=>{ const groups = new Map(); for (const pack of host.packs ?? []) { - const key = verb[pack.action]; + const key = pack_verb(report_action, pack.action); groups.set(key, [...(groups.get(key) ?? []), pack.name]); } if (!groups.size) @@ -70,7 +75,7 @@ const human_lines = (report: Report): string[]=>{ } for (const host of report.hosts) { - lines.push(host_line(host)); + lines.push(host_line(host, report.action)); if (host.hint) { lines.push(pc.dim(` fix: ${host.hint}`)); From 4455ec89bfab6d9657f7dd674eb4d56d92b2851a Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 17:56:36 -0700 Subject: [PATCH 21/32] fix: make skills summary.installed action-aware for list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For install/update/remove, summary.installed still counts hosts by outcome status, unchanged. For list — a read-only query — a host answering "ok" says nothing about whether it actually has any pack; summarize now counts a list host as installed only when it reports at least one pack, so `skills list --json` on a clean machine reports summary.installed: 0 instead of a false-positive count of queryable hosts. skipped/failed keep their existing host-oriented meaning. --- src/__tests__/skills/report.test.ts | 44 +++++++++++++++++++++-------- src/skills/orchestrate.ts | 2 +- src/skills/report.ts | 10 +++++-- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/__tests__/skills/report.test.ts b/src/__tests__/skills/report.test.ts index 57774b3..0143077 100644 --- a/src/__tests__/skills/report.test.ts +++ b/src/__tests__/skills/report.test.ts @@ -14,24 +14,46 @@ const host = (over: Partial = {}): Host_outcome=>({ ...over, }); -const report = (hosts: Host_outcome[], over: Partial = {}): Report=>({ - action: 'install', - source: {repo: 'reply-team/reply-skills', ref: 'main'}, - requested: ['ai-sdr-core', 'reply-adapter'], - resolved: ['ai-sdr-core', 'reply-adapter'], - hosts, - summary: summarize(hosts), - ...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'})])) + 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'})])).toEqual({installed: 1, skipped: 0, failed: 0}); + 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}); }); }); diff --git a/src/skills/orchestrate.ts b/src/skills/orchestrate.ts index 15091fc..0538baf 100644 --- a/src/skills/orchestrate.ts +++ b/src/skills/orchestrate.ts @@ -150,7 +150,7 @@ const run_skills = async(opts: Skills_opts): Promise=>{ requested: opts.requested.length ? canonical : packs.map(p=>p.name), resolved: packs.map(p=>p.name), hosts, - summary: summarize(hosts), + summary: summarize(hosts, opts.operation), }; }; diff --git a/src/skills/report.ts b/src/skills/report.ts index 50ac1d9..ccc5a29 100644 --- a/src/skills/report.ts +++ b/src/skills/report.ts @@ -5,8 +5,14 @@ import type {Host_outcome, Operation, Pack_action, Report} from './types'; // returns. The report names only what was found: an assistant that is not on // the machine is never mentioned. -const summarize = (hosts: Host_outcome[]): Report['summary']=>({ - installed: hosts.filter(h=>h.status === 'ok' || h.status === 'partial').length, +// `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, }); From b55f74d750f047bb4165c428d02bed13e5471627 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 18:07:05 -0700 Subject: [PATCH 22/32] test: real-host smoke check for skills install The smoke script tests the installer against Claude Code, Codex, and flat-directory hosts with proper sandbox isolation. Revealed and fixed a bug in adapter-native where Claude Code's updated plugin list format (direct array with id field) was not being parsed correctly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- CONTRIBUTING.md | 6 ++ package.json | 1 + scripts/smoke-hosts.mjs | 111 +++++++++++++++++++++++++++++++++++ src/skills/adapter-native.ts | 25 +++++++- 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke-hosts.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8de2f72..f6e6a91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,12 @@ 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 installs the skill packs into the +assistants actually present on your machine (Claude Code, Codex). It is not part +of `npm test` because it needs those assistants installed and it clones from +GitHub. It is safe to run: each host is pointed at a throwaway config directory +(`CLAUDE_CONFIG_DIR`, `CODEX_HOME`), so your own plugin state is untouched. + ## Conventions - Data is written to stdout; status and error messages go to stderr. 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..9518750 --- /dev/null +++ b/scripts/smoke-hosts.mjs @@ -0,0 +1,111 @@ +// Verifies `reply skills install` against really installed assistants. +// +// Not part of `npm test`: it needs Claude Code and/or Codex on the machine and +// it clones from GitHub. It is safe to run on a working machine because each +// host is pointed at a throwaway configuration directory — CLAUDE_CONFIG_DIR +// and CODEX_HOME — so your real plugin state is never touched. +// +// 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 both environment-variable config directories (for native hosts) +// and relative directories (for detection in the sandbox home) +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; +}; + +try { + console.log(`sandbox: ${sandbox}`); + + // Safety assertion: verify the sandbox is truly isolated before making any changes. + // This proves that environment variables are honored and the developer's real config is safe. + 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 in throwaway config directory. Environment variables may be ignored. Aborting without making changes.'); + process.exit(1); + } + console.log('✓ sandbox isolation verified: no pre-existing plugins in throwaway config directories'); + + const installed = JSON.parse(cli('skills', 'install', '--json')); + console.log(`hosts: ${installed.hosts.map(h=>`${h.host}=${h.status}`).join(' ') || '(none detected)'}`); + if (!installed.hosts.length) + { + console.log('⚠ no assistant detected — nothing to verify on this machine'); + process.exit(0); + } + 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'); + } + + if (!process.exitCode) + { + console.log('✓ smoke passed'); + } +} finally { + fs.rmSync(sandbox, {recursive: true, force: true}); +} diff --git a/src/skills/adapter-native.ts b/src/skills/adapter-native.ts index 9309b56..6765d5a 100644 --- a/src/skills/adapter-native.ts +++ b/src/skills/adapter-native.ts @@ -36,12 +36,33 @@ const installed_versions = async(host: Detected_host, run: Runner): Promise; - const rows = [doc.plugins, doc.installed].find(Array.isArray) as Record[] | undefined; + const rows = Array.isArray(parsed) + ? parsed + : [doc.plugins, doc.installed].find(Array.isArray) as Record[] | undefined; const out: Record = {}; for (const row of rows ?? []) { - const name = row.name; + // 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; From 1b1648d52f56c59a5d2a7f746ede6b30389e224f Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 18:11:54 -0700 Subject: [PATCH 23/32] test: fix sandbox isolation for flat hosts with post-run verification The initial smoke script had a critical isolation flaw: flat-directory hosts (cursor, gemini-cli, github-copilot) were writing to the real home despite HOME/USERPROFILE redirection. This was caught in testing before deployment. Fixes: 1. Strengthened pre-flight assertion to verify sandbox is clean 2. Added post-run assertion that verifies the real home remains untouched by checking that no reply skill directories were created there 3. If either assertion fails, script aborts non-zero without installing Isolation mechanisms: - Native hosts (Claude Code, Codex): CLAUDE_CONFIG_DIR, CODEX_HOME - Flat hosts (Cursor, Gemini, Copilot): HOME/USERPROFILE redirect to sandbox - Detection: script creates marker directories in sandbox so hosts are found there The post-run assertion makes this smoke test safe: if isolation ever breaks, the assertion catches it and proves the failure via the assertion exit code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- scripts/smoke-hosts.mjs | 58 +++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/scripts/smoke-hosts.mjs b/scripts/smoke-hosts.mjs index 9518750..f617f69 100644 --- a/scripts/smoke-hosts.mjs +++ b/scripts/smoke-hosts.mjs @@ -1,9 +1,12 @@ // Verifies `reply skills install` against really installed assistants. // // Not part of `npm test`: it needs Claude Code and/or Codex on the machine and -// it clones from GitHub. It is safe to run on a working machine because each -// host is pointed at a throwaway configuration directory — CLAUDE_CONFIG_DIR -// and CODEX_HOME — so your real plugin state is never touched. +// it clones from GitHub. It is safe to run on a working machine because: +// - Native hosts (Claude Code, Codex) use CLAUDE_CONFIG_DIR and CODEX_HOME +// - Flat hosts (Cursor, Gemini, Copilot) use HOME/USERPROFILE redirected to sandbox +// The script verifies isolation before and after: pre-flight checks that all hosts +// resolve inside the sandbox, post-run checks that the real home is untouched. +// If either assertion fails, the script aborts without installing. // // Usage: npm run build && npm run smoke:hosts @@ -49,18 +52,18 @@ const fail = (message)=>{ try { console.log(`sandbox: ${sandbox}`); - // Safety assertion: verify the sandbox is truly isolated before making any changes. - // This proves that environment variables are honored and the developer's real config is safe. + // Safety assertion: verify sandbox is truly isolated BEFORE making any changes. + // Pre-flight: no pre-existing plugins in the sandbox (proof it's clean). + // Post-flight: real home is untouched (below, after the run). 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 in throwaway config directory. Environment variables may be ignored. Aborting without making changes.'); + fail('sandbox isolation check failed: plugins already installed. Aborting without making changes.'); process.exit(1); } - console.log('✓ sandbox isolation verified: no pre-existing plugins in throwaway config directories'); + console.log('✓ pre-flight assertion: sandbox is clean'); const installed = JSON.parse(cli('skills', 'install', '--json')); console.log(`hosts: ${installed.hosts.map(h=>`${h.host}=${h.status}`).join(' ') || '(none detected)'}`); @@ -102,9 +105,48 @@ try { console.log('✓ removing a needed dependency is refused'); } + // Post-run assertion: verify the real home was not modified by the smoke test. + // This is the critical safety check that proves isolation worked end-to-end. + const real_home = os.homedir(); + const real_copilot_skills = path.join(real_home, '.copilot', 'skills'); + const real_cursor_skills = path.join(real_home, '.cursor', 'skills'); + const real_reply_skills_json = path.join(real_home, '.reply', 'skills.json'); + + // Check that no reply skill packs were installed to the real home + const check_dir = (dir)=>{ + if (!fs.existsSync(dir)) + { + return []; + } + try { + return fs.readdirSync(dir); + } catch { + return []; + } + }; + + const copilot_skills = check_dir(real_copilot_skills); + const reply_skill_names = ['ai-sdr-core', 'reply-adapter', 'agentic-runtime']; + const installed_in_real_copilot = copilot_skills.some(s=>reply_skill_names.some(r=>s.includes(r))); + + if (installed_in_real_copilot) + { + fail(`post-run assertion failed: reply skills were installed to the real home at ${real_copilot_skills}`); + process.exit(1); + } + + const cursor_skills = check_dir(real_cursor_skills); + const installed_in_real_cursor = cursor_skills.some(s=>reply_skill_names.some(r=>s.includes(r))); + if (installed_in_real_cursor) + { + fail(`post-run assertion failed: reply skills were installed to the real home at ${real_cursor_skills}`); + process.exit(1); + } + if (!process.exitCode) { console.log('✓ smoke passed'); + console.log('✓ post-run assertion confirmed: real home is untouched'); } } finally { fs.rmSync(sandbox, {recursive: true, force: true}); From 41b8b8490f03d4c4521e0f5f70c276578021d821 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 18:14:35 -0700 Subject: [PATCH 24/32] test: replace name-based post-run assertion with snapshot comparison The initial post-run assertion was vacuous: it checked for pack names ('ai-sdr-core', 'reply-adapter') but what lands on disk are skill directories ('approval-boundaries', 'audience-building', etc.). The check could not fire even on the actual contamination it missed. Replaced with snapshot-based comparison: - Before any CLI invocation, snapshot all real flat-host directories: ~/.copilot/skills, ~/.cursor/skills, ~/.gemini/skills, ~/.codeium/windsurf/skills, ~/.agents/skills, and the reply config dir - After the smoke test, snapshot again and assert identical - Any filesystem change (new entry, removed file, directory created/deleted) fails the script non-zero and names the exact delta This design requires no knowledge of what gets installed. It catches any isolation failure: if HOME/USERPROFILE redirection breaks, the CLI writes to the real home, changing its snapshot. The assertion also triggers on read-only commands that unexpectedly write, catching unexpected side effects. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- scripts/smoke-hosts.mjs | 155 +++++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 41 deletions(-) diff --git a/scripts/smoke-hosts.mjs b/scripts/smoke-hosts.mjs index f617f69..63d7f33 100644 --- a/scripts/smoke-hosts.mjs +++ b/scripts/smoke-hosts.mjs @@ -4,9 +4,14 @@ // it clones from GitHub. It is safe to run on a working machine because: // - Native hosts (Claude Code, Codex) use CLAUDE_CONFIG_DIR and CODEX_HOME // - Flat hosts (Cursor, Gemini, Copilot) use HOME/USERPROFILE redirected to sandbox -// The script verifies isolation before and after: pre-flight checks that all hosts -// resolve inside the sandbox, post-run checks that the real home is untouched. -// If either assertion fails, the script aborts without installing. +// +// The script uses snapshot-based comparison to prove the real home is untouched: +// - Before any CLI invocation, snapshot all real flat-host skills directories +// - After the smoke test, verify the snapshots are identical +// - Any filesystem change (new dir, removed entry) fails the script non-zero +// +// 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 @@ -24,8 +29,7 @@ const env = { CODEX_HOME: path.join(sandbox, 'codex'), REPLY_CONFIG_DIR: path.join(sandbox, 'reply'), }; -// Create both environment-variable config directories (for native hosts) -// and relative directories (for detection in the sandbox home) +// Create marker directories in sandbox so detection works (otherwise would find nothing) const config_dirs = [ env.CLAUDE_CONFIG_DIR, env.CODEX_HOME, @@ -49,12 +53,108 @@ const fail = (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. +const snapshot_state = ()=>{ + const real_home = os.homedir(); + const paths_to_check = [ + path.join(real_home, '.copilot', 'skills'), + path.join(real_home, '.cursor', 'skills'), + path.join(real_home, '.gemini', 'skills'), + path.join(real_home, '.codeium', 'windsurf', 'skills'), + 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(); + // Safety assertion: verify sandbox is truly isolated BEFORE making any changes. - // Pre-flight: no pre-existing plugins in the sandbox (proof it's clean). - // Post-flight: real home is untouched (below, after the run). 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); @@ -105,48 +205,21 @@ try { console.log('✓ removing a needed dependency is refused'); } - // Post-run assertion: verify the real home was not modified by the smoke test. - // This is the critical safety check that proves isolation worked end-to-end. - const real_home = os.homedir(); - const real_copilot_skills = path.join(real_home, '.copilot', 'skills'); - const real_cursor_skills = path.join(real_home, '.cursor', 'skills'); - const real_reply_skills_json = path.join(real_home, '.reply', 'skills.json'); - - // Check that no reply skill packs were installed to the real home - const check_dir = (dir)=>{ - if (!fs.existsSync(dir)) - { - return []; - } - try { - return fs.readdirSync(dir); - } catch { - return []; - } - }; - - const copilot_skills = check_dir(real_copilot_skills); - const reply_skill_names = ['ai-sdr-core', 'reply-adapter', 'agentic-runtime']; - const installed_in_real_copilot = copilot_skills.some(s=>reply_skill_names.some(r=>s.includes(r))); - - if (installed_in_real_copilot) - { - fail(`post-run assertion failed: reply skills were installed to the real home at ${real_copilot_skills}`); - process.exit(1); - } + // Post-run assertion: snapshot the real environment again and verify it is unchanged. + // This is the critical safety check. Any difference means isolation failed. + const after_snapshot = snapshot_state(); + const diffs = compare_snapshots(before_snapshot, after_snapshot); - const cursor_skills = check_dir(real_cursor_skills); - const installed_in_real_cursor = cursor_skills.some(s=>reply_skill_names.some(r=>s.includes(r))); - if (installed_in_real_cursor) + if (diffs.length > 0) { - fail(`post-run assertion failed: reply skills were installed to the real home at ${real_cursor_skills}`); + fail(`post-run assertion failed: real home was modified:\n${diffs.map(d=>` ${d}`).join('\n')}`); process.exit(1); } + console.log('✓ post-run assertion: real home is untouched'); if (!process.exitCode) { console.log('✓ smoke passed'); - console.log('✓ post-run assertion confirmed: real home is untouched'); } } finally { fs.rmSync(sandbox, {recursive: true, force: true}); From d226b55435dfdd5d1db3da65dfbf51354faec0f8 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 18:22:59 -0700 Subject: [PATCH 25/32] test: run post-run assertion unconditionally, never skip via process.exit The snapshot-based post-run assertion (previous commit) was itself unreachable in two of its three exit paths: process.exit() inside a try block skips the try's finally entirely, so a sandbox-isolation pre-flight failure or a "no assistant detected" early exit both bypassed the post-run comparison and the sandbox cleanup. The one path that did reach the comparison then called process.exit(1) again, still skipping cleanup. Restructured so every functional assertion (pre-flight isolation, host detection, resolve order, idempotency, selective install, dependency guard) lives inside a nested try/catch that reports failures via process.exitCode instead of aborting, and an unexpected thrown error is caught and reported the same way. Control always falls through to the post-run snapshot comparison and then to the outer finally, so a failing smoke run still proves (or disproves) that the real home was touched, and the sandbox is always removed. Verified by temporarily writing into the real ~/.copilot/skills between the two snapshots: the assertion reported the exact delta and exited non-zero, then the change was reverted and the probe removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- scripts/smoke-hosts.mjs | 110 +++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 47 deletions(-) diff --git a/scripts/smoke-hosts.mjs b/scripts/smoke-hosts.mjs index 63d7f33..a9cf837 100644 --- a/scripts/smoke-hosts.mjs +++ b/scripts/smoke-hosts.mjs @@ -154,68 +154,84 @@ try { // This catches even read-only operations that unexpectedly write. const before_snapshot = snapshot_state(); - // Safety assertion: verify sandbox is truly isolated BEFORE making any changes. - 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.'); - process.exit(1); - } - console.log('✓ pre-flight assertion: sandbox is clean'); + // 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`. + try { + // Safety assertion: verify sandbox is truly isolated BEFORE making any changes. + 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.'); + } + else + { + console.log('✓ pre-flight assertion: sandbox is clean'); - const installed = JSON.parse(cli('skills', 'install', '--json')); - console.log(`hosts: ${installed.hosts.map(h=>`${h.host}=${h.status}`).join(' ') || '(none detected)'}`); - if (!installed.hosts.length) - { - console.log('⚠ no assistant detected — nothing to verify on this machine'); - process.exit(0); - } - 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(',')}`); - } + const installed = JSON.parse(cli('skills', 'install', '--json')); + console.log(`hosts: ${installed.hosts.map(h=>`${h.host}=${h.status}`).join(' ') || '(none detected)'}`); + if (!installed.hosts.length) + { + console.log('⚠ no assistant detected — nothing to verify on this machine'); + } + else + { + 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(',')}`); - } + // 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(',')}`); - } + // 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'); + // 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')}`); - process.exit(1); } - console.log('✓ post-run assertion: real home is untouched'); + else + { + console.log('✓ post-run assertion: real home is untouched'); + } if (!process.exitCode) { From d8fe6a0ddb825c05a49481f040ea8f23b9b05c73 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 18:30:06 -0700 Subject: [PATCH 26/32] test: watch host root directories, not just their skills leaf snapshot_state() watched each flat host's skills leaf directory (~/.cursor/skills, ~/.copilot/skills, etc.) but not the host's root config directory (~/.cursor, ~/.copilot, etc.) that detect_hosts actually keys presence on. An empty root created with no skills subdirectory inside it was invisible to the comparison: both snapshots recorded DOES_NOT_EXIST for the leaf, and the assertion reported "untouched" even though the next `reply skills install` on that machine would now detect and write into that host. Added each host's root directory to the snapshot set alongside its leaf. Verified with two probes between the two snapshots, each reverted after: - leaf case: an entry created inside real ~/.copilot/skills is reported as "ADDED to : zz-smoke-probe" - root case: a bare ~/.cursor created with nothing inside it (the case this commit exists to catch) is reported as "CREATED: " Both exited non-zero. Re-ran clean afterward to confirm a passing run still reports real home untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- scripts/smoke-hosts.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/smoke-hosts.mjs b/scripts/smoke-hosts.mjs index a9cf837..7b8502a 100644 --- a/scripts/smoke-hosts.mjs +++ b/scripts/smoke-hosts.mjs @@ -74,13 +74,24 @@ const real_reply_config_dir = ()=>{ // 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(), ]; From f9d17c72ef8126cc44ec66350a8ce08c38464cac Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 18:51:28 -0700 Subject: [PATCH 27/32] test: prove real isolation directly, make native detection honest, pin the id-shape parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Important findings from task 10's review, all fixed: 1. The pre-flight assertion never proved HOME/USERPROFILE redirection was in effect. It only checked that the sandboxed journal reported no packs, but journal reads are gated by REPLY_CONFIG_DIR, which is set unconditionally regardless of whether HOME/USERPROFILE redirection works — so a fully broken redirect would still read "clean". Added a direct proof before anything mutating runs: spawn a child with the exact same env used for the CLI and assert its own os.homedir() resolves inside the sandbox. Kept the journal-emptiness check as a secondary sanity check, re-commented to state what it actually establishes. 2. Marker directories are created unconditionally for every flat host (Cursor, Gemini CLI, GitHub Copilot), so they always report "detected" regardless of the real machine, making the brief's "no assistant at all" exit-0 path dead code, and making CONTRIBUTING.md's claim that the script "installs into the assistants actually present on your machine" false for those three. Documented the real behavior in both the script header and CONTRIBUTING.md: flat hosts are always simulated inside the sandbox (deliberately — it's the only way that install path gets exercised at all), native hosts (Claude Code, Codex) are only genuinely exercised when really installed. Replaced the dead "no assistant" check with a reachable, meaningful one keyed on native-host status specifically (a native host reporting 'skipped' means its marker existed but the real binary could not be resolved from the real PATH — i.e. not really installed here). 3. The adapter-native.ts fix for Claude Code's new direct-array-with-id `plugin list --json` shape shipped with no regression test, so a future refactor could silently reintroduce the exact bug that shipped broken until a real-host run caught it. Added tests using the real shape, captured from `claude plugin list --json` (Claude Code 2.1.220) on this machine: a positive case (our marketplace via the id suffix maps to a version) and two negative cases proving marketplace filtering still applies in the new shape — the real foreign-marketplace row seen on this machine (elastic-agent-skills), and a same-pack-name/foreign-marketplace row exercised through the full run_native install flow, mirroring the existing test for the old {plugins:[...]} envelope. Verified: npm run build, npm test (395 passed, 3 skipped, up from 392 — the 3 new adapter-native tests), and a clean npm run smoke:hosts showing both new pre-flight lines and the native-host advisory. Real home unchanged throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- CONTRIBUTING.md | 17 +++- scripts/smoke-hosts.mjs | 107 ++++++++++++++++---- src/__tests__/skills/adapter-native.test.ts | 37 +++++++ 3 files changed, 139 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6e6a91..a0088bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,11 +24,18 @@ 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 installs the skill packs into the -assistants actually present on your machine (Claude Code, Codex). It is not part -of `npm test` because it needs those assistants installed and it clones from -GitHub. It is safe to run: each host is pointed at a throwaway config directory -(`CLAUDE_CONFIG_DIR`, `CODEX_HOME`), so your own plugin state is untouched. +`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 diff --git a/scripts/smoke-hosts.mjs b/scripts/smoke-hosts.mjs index 7b8502a..85e5052 100644 --- a/scripts/smoke-hosts.mjs +++ b/scripts/smoke-hosts.mjs @@ -1,14 +1,29 @@ -// Verifies `reply skills install` against really installed assistants. +// Verifies `reply skills install` end to end, against real and simulated assistants. // -// Not part of `npm test`: it needs Claude Code and/or Codex on the machine and -// it clones from GitHub. It is safe to run on a working machine because: -// - Native hosts (Claude Code, Codex) use CLAUDE_CONFIG_DIR and CODEX_HOME -// - Flat hosts (Cursor, Gemini, Copilot) use HOME/USERPROFILE redirected to sandbox +// 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, snapshot all real flat-host skills directories -// - After the smoke test, verify the snapshots are identical -// - Any filesystem change (new dir, removed entry) fails the script non-zero +// - 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. @@ -169,26 +184,84 @@ try { // 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 { - // Safety assertion: verify sandbox is truly isolated BEFORE making any changes. - 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) + // 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('sandbox isolation check failed: plugins already installed. Aborting without making changes.'); + 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: sandbox is clean'); + 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 assistant detected — nothing to verify on this machine'); + console.log('⚠ no hosts detected at all — nothing to verify on this machine'); + can_proceed = false; } - else + + if (can_proceed) { if (!installed.hosts.some(h=>h.status === 'ok')) { diff --git a/src/__tests__/skills/adapter-native.test.ts b/src/__tests__/skills/adapter-native.test.ts index 6b83388..159099a 100644 --- a/src/__tests__/skills/adapter-native.test.ts +++ b/src/__tests__/skills/adapter-native.test.ts @@ -24,6 +24,19 @@ const claude_list = (packs: {name: string; version: string}[]): string=>JSON.str 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; @@ -46,6 +59,20 @@ describe('installed_versions', ()=>{ 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', ()=>{ @@ -228,6 +255,16 @@ describe('run_native list and update', ()=>{ 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: []}; From 36ae3a7c7f051d7f767dfec4bcec5032aff36f3d Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 19:23:50 -0700 Subject: [PATCH 28/32] fix(skills): protect the core-before-dependents invariant on remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install blocks a dependent whose dependency failed, transitively; remove only reversed the order. A failed `plugin uninstall reply-adapter` followed by a successful `ai-sdr-core` removal left the host holding an adapter with no core — the one state this feature exists to prevent. Both adapters now carry the transposed guard: a pack is never removed once a pack that depends on it failed or was itself blocked, the block propagates down the chain, and the kept packs are named in the host hint. In the flat adapter the guard is only meaningful because delete_files no longer swallows its errors: `fs.rmSync(file, {force})` does not clear the read-only attribute on Windows and throws EPERM, so a pack was reported `removed` with the file still on disk. Both a refused path (outside the skills directory) and a failed delete now report the pack `failed` and keep its journal entry for a retry. Two more findings in the same code paths: - A project-scope journal entry now records the repository it belongs to. The key is host -> scope -> pack, which cannot tell two checkouts apart, so `remove --project` from a second repository deleted nothing (containment correctly refused every path) and still forgot the entry and reported `removed`. An entry from another project is now invisible to this run: not read, not replaced, not forgotten. User scope is unchanged. - An update that did not move the version reports `current`, never `upgraded X -> X`. The Codex marketplace path exits 0 whether or not anything moved and used to hardcode `upgraded`; the flat adapter re-copies from a fresh clone, which the commit records, not the version. Both now agree with the Claude Code per-pack path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/adapter-flat.test.ts | 149 +++++++++++++++- src/__tests__/skills/adapter-native.test.ts | 86 ++++++++- src/skills/adapter-flat.ts | 186 +++++++++++++++++--- src/skills/adapter-native.ts | 103 ++++++++--- src/skills/journal.ts | 7 + 5 files changed, 469 insertions(+), 62 deletions(-) diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts index bcf8fc6..20003d7 100644 --- a/src/__tests__/skills/adapter-flat.test.ts +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -252,7 +252,7 @@ describe('run_flat copy failures', ()=>{ // 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('does not delete a journaled path outside the skills directory', async()=>{ + 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', { @@ -261,7 +261,33 @@ describe('run_flat deletion containment', ()=>{ }, env()); const outcome = await run_flat(flat_opts('remove', core_only)); expect(fs.existsSync(outside)).toBe(true); - expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'removed', version: '0.1.0'}]); + // 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()=>{ @@ -336,6 +362,106 @@ describe('run_flat dependency blocking', ()=>{ }); }); +// 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 @@ -416,7 +542,7 @@ describe('clone_repo', ()=>{ // 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('re-attempts a pack whose entry is marked incomplete, even at the target version, instead of reporting current', async()=>{ + 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', @@ -424,8 +550,13 @@ describe('run_flat incomplete installs', ()=>{ 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: 'upgraded'}, + {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); @@ -533,11 +664,15 @@ describe('run_flat case-insensitive path comparisons', ()=>{ fs.writeFileSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'), 'old content'); // `update`, not `install`: the entry is already complete at the - // target version, so `install` would report `current` without ever - // reaching the collision/ownership check this test exercises. + // target version, so `install` would skip the copy entirely without + // ever reaching the collision/ownership check this test exercises. const outcome = await run_flat(flat_opts('update', core_only)); - expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'upgraded', version: '0.1.0', from: '0.1.0'}]); + // Re-copied from a fresh clone, but at the same version — so `current` + // (I3), and the file is proof the copy itself was not skipped. + expect(outcome.packs).toEqual([{name: 'ai-sdr-core', action: 'current', version: '0.1.0'}]); expect(fs.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); + expect(fs.readFileSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'), 'utf8')) + .not.toBe('old content'); }); }); diff --git a/src/__tests__/skills/adapter-native.test.ts b/src/__tests__/skills/adapter-native.test.ts index 159099a..4d6fc54 100644 --- a/src/__tests__/skills/adapter-native.test.ts +++ b/src/__tests__/skills/adapter-native.test.ts @@ -3,7 +3,7 @@ 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 {Run_result, Runner} from '../../skills/types'; +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}); @@ -181,6 +181,51 @@ describe('run_native remove', ()=>{ 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', ()=>{ @@ -216,6 +261,45 @@ describe('run_native list and update', ()=>{ 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}); diff --git a/src/skills/adapter-flat.ts b/src/skills/adapter-flat.ts index 568b553..d8b1535 100644 --- a/src/skills/adapter-flat.ts +++ b/src/skills/adapter-flat.ts @@ -80,25 +80,47 @@ const is_within = (root: string, target: string): boolean=>{ 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()): void=>{ +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) || [...protected_files].some(p=>paths_equal(p, resolved))) + 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 { - // Already gone — removal stays idempotent. + } catch (error) { + failed.push({file: resolved, message: (error as Error).message}); + continue; } dirs.add(path.dirname(resolved)); } @@ -117,13 +139,39 @@ const delete_files = (files: string[], target_root: string, protected_files: Set // 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. -const claimed_by_others = (env: Env | undefined, scope: Scope, pack_name: string, exclude_host: string): Set=>{ +// `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)) @@ -137,6 +185,11 @@ const claimed_by_others = (env: Env | undefined, scope: Scope, pack_name: string { 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)); @@ -175,6 +228,37 @@ const status_of = (packs: Pack_outcome[]): Host_outcome['status']=>{ 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. +const copied_outcome = (name: string, version: string, from?: string): Pack_outcome=>{ + if (from === undefined) + { + return {name, action: 'installed', version}; + } + return from === version + ? {name, action: 'current', version} + : {name, action: 'upgraded', version, from}; +}; + type Flat_opts = { operation: Operation; host: Detected_host; @@ -203,14 +287,34 @@ const run_flat = async(opts: Flat_opts): Promise=>{ 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 `scope` can never be forgotten at a call site. - const entry_for = (pack_name: string): Journal_entry | undefined=> - journal_entry(id, scope, pack_name, opts.env); + // 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, data, opts.env); + 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') @@ -257,22 +361,54 @@ const run_flat = async(opts: Flat_opts): Promise=>{ 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) + if (dry_run) { - const protected_files = claimed_by_others(opts.env, scope, pack.name, id); - delete_files(entry.files, target_root, protected_files); - forget_for(pack.name); + 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}; + return {...base, packs: outcomes, status: status_of(outcomes), hint: kept_hint(kept_names)}; } // install and update both need the repository contents. update only touches @@ -299,10 +435,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ { for (const pack of pending) { - const entry = entry_for(pack.name); - outcomes.push(entry - ? {name: pack.name, action: 'upgraded', version: pack.version, from: entry.version} - : {name: pack.name, action: 'installed', version: pack.version}); + outcomes.push(copied_outcome(pack.name, pack.version, entry_for(pack.name)?.version)); } return {...base, packs: outcomes}; } @@ -335,10 +468,6 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // way regardless of which adapter is doing the installing. const failed_names = new Set(); const blocked_names = new Set(); - const blocked_hint = (): string | undefined=> - blocked_names.size - ? `packs ${[...blocked_names].join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` - : undefined; try { for (const pack of pending) { @@ -350,7 +479,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ } const from = path.join(cloned.dir, 'plugins', pack.name, 'skills'); const previous = entry_for(pack.name); - const elsewhere = claimed_by_others(opts.env, scope, pack.name, id); + const elsewhere = others_claim(pack.name); const known_files = previous ? [...previous.files.map(f=>path.resolve(f)), ...elsewhere] : [...elsewhere]; @@ -371,6 +500,9 @@ const run_flat = async(opts: Flat_opts): Promise=>{ } 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[] = []; @@ -399,9 +531,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ complete: true, installed_at: new Date().toISOString(), }); - outcomes.push(previous - ? {name: pack.name, action: 'upgraded', version: pack.version, from: previous.version} - : {name: pack.name, action: 'installed', version: pack.version}); + outcomes.push(copied_outcome(pack.name, pack.version, previous?.version)); } } catch (error) { // outcomes.length is not "something landed" — every entry pushed so @@ -413,7 +543,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ packs: outcomes, reason: 'copy-failed', detail: (error as Error).message, - hint: blocked_hint() ?? 'check filesystem permissions for the skills directory, then re-run', + hint: blocked_hint(blocked_names) ?? 'check filesystem permissions for the skills directory, then re-run', }; } finally { try { @@ -423,7 +553,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ // the result computed above. } } - return {...cloned_base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint()}; + return {...cloned_base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint(blocked_names)}; }; export {clone_repo, copy_dir, skills_target, run_flat}; diff --git a/src/skills/adapter-native.ts b/src/skills/adapter-native.ts index 6765d5a..c64f87f 100644 --- a/src/skills/adapter-native.ts +++ b/src/skills/adapter-native.ts @@ -106,6 +106,34 @@ const status_of = (packs: Pack_outcome[]): Host_outcome['status']=>{ 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; @@ -180,8 +208,32 @@ const run_native = async(opts: Native_opts): Promise=>{ } 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; @@ -192,11 +244,15 @@ const run_native = async(opts: Native_opts): Promise=>{ continue; } const result = await run(host.bin, cli.remove(pack.name, MARKETPLACE)); - outcomes.push(result.code === 0 - ? {name: pack.name, action: 'removed', version: installed[pack.name]} - : {name: pack.name, action: 'failed', detail: (result.stderr || result.stdout).trim()}); + 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)}; + return {...base, packs: outcomes, status: status_of(outcomes), hint: kept_hint(kept_names)}; } if (operation === 'update') @@ -224,7 +280,7 @@ const run_native = async(opts: Native_opts): Promise=>{ { for (const pack of installed_packs) { - outcomes.push({name: pack.name, action: 'upgraded', version: pack.version, from: installed[pack.name]}); + outcomes.push(updated_outcome(pack.name, installed[pack.name], pack.version)); } } else @@ -233,7 +289,10 @@ const run_native = async(opts: Native_opts): Promise=>{ const result = await run(host.bin, cli.update(installed_packs[0].name, MARKETPLACE)); if (result.code === 0) { - // Re-read listing to get actual versions + // 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) { @@ -241,12 +300,9 @@ const run_native = async(opts: Native_opts): Promise=>{ for (const pack of installed_packs) { const have = pre_update_versions.get(pack.name); - outcomes.push({ - name: pack.name, - action: 'upgraded', - version: post_installed[pack.name] ?? pack.version, - from: have ?? '', - }); + outcomes.push(updated_outcome( + pack.name, have ?? '', post_installed[pack.name] ?? pack.version, + )); } } else @@ -255,7 +311,7 @@ const run_native = async(opts: Native_opts): Promise=>{ for (const pack of installed_packs) { const have = pre_update_versions.get(pack.name); - outcomes.push({name: pack.name, action: 'upgraded', version: pack.version, from: have ?? ''}); + outcomes.push(updated_outcome(pack.name, have ?? '', pack.version)); } } } @@ -294,21 +350,20 @@ const run_native = async(opts: Native_opts): Promise=>{ const result = await run(host.bin, cli.update(pack.name, MARKETPLACE)); if (result.code === 0) { - // Re-read listing to get actual version + // 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({ - name: pack.name, - action: 'upgraded', - version: post_listing.versions[pack.name] ?? pack.version, - from: have, - }); + 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({name: pack.name, action: 'upgraded', version: pack.version, from: have}); + outcomes.push(updated_outcome(pack.name, have, pack.version)); } } else @@ -360,11 +415,7 @@ const run_native = async(opts: Native_opts): Promise=>{ : {name: pack.name, action, version: pack.version}); } - const status = status_of(outcomes); - const hint = blocked_names.size - ? `packs ${[...blocked_names].join(', ')} were not attempted because their dependencies failed; fix those installs and re-run` - : undefined; - return {...base, packs: outcomes, status, hint}; + return {...base, packs: outcomes, status: status_of(outcomes), hint: blocked_hint(blocked_names)}; }; export {default_runner, installed_versions, run_native}; diff --git a/src/skills/journal.ts b/src/skills/journal.ts index 4d084a4..e92ba3f 100644 --- a/src/skills/journal.ts +++ b/src/skills/journal.ts @@ -15,6 +15,13 @@ type Journal_entry = { 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 From 55bfe40efb2e75fe5d4d0a46585132b1963d7214 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 19:24:09 -0700 Subject: [PATCH 29/32] fix(skills): send list data to stdout and word the exit-1 error per operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract-level slips in the shared handler. `reply skills list` is a data command, but every human-mode line went to stderr, so `skills list 2>/dev/null` printed nothing and `skills list > installed.txt` produced an empty file — the opposite of the repo's output contract. Its table now goes to stdout; install, update and remove print progress, which is status, and stay on stderr. The test that pinned the old behaviour asserted it as intentional, so it is replaced by one assertion per side of the split. The exit-1 error was install-worded for all four operations: a failed `remove` said "No assistant received the skills." and pointed at `skills install --dry-run`. Title, code and hint now come from the operation that actually ran. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/commands/skills.test.ts | 52 +++++++++++++++++++++++++-- src/commands/skills.ts | 41 ++++++++++++++++++--- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/__tests__/commands/skills.test.ts b/src/__tests__/commands/skills.test.ts index 8b92f21..ad5b74b 100644 --- a/src/__tests__/commands/skills.test.ts +++ b/src/__tests__/commands/skills.test.ts @@ -48,7 +48,10 @@ afterEach(()=>{ }); describe('handle_skills', ()=>{ - it('prints the human summary on stderr and nothing on stdout', async()=>{ + // 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'); @@ -56,6 +59,27 @@ describe('handle_skills', ()=>{ 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})); @@ -83,7 +107,31 @@ describe('handle_skills', ()=>{ mock_run_skills.mockResolvedValue(report({ hosts: [], summary: {installed: 0, skipped: 0, failed: 0}, })); - await expect(capture(()=>handle_skills('install', [], {}))).rejects.toMatchObject({exit_code: 1}); + 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()=>{ diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 9a4c830..c3e4cc5 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -22,6 +22,34 @@ const read_globals = (cmd: Command): Skills_cli_opts=>{ 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( @@ -43,9 +71,13 @@ const handle_skills = async( } 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)) { - console.error(line); + write(line); } } @@ -53,9 +85,10 @@ const handle_skills = async( // why each host failed. if (exit_code_for(report) !== 0) { - throw new RuntimeError('No assistant received the skills.', { - code: 'skills.nothing_installed', - hint: `run \`${PROGRAM_NAME} skills install --dry-run\` to see what was attempted`, + 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`, }); } }; From 17e1bc7bbdb3111c20b199feaee6a3766ed91fab Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 19:24:31 -0700 Subject: [PATCH 30/32] feat(skills): tell the user which assistants have unverified paths `verified: false` was declared on four of six hosts, asserted by one registry test, and read by nothing: not the README, not the report, not `--json`. A Cursor or Windsurf user got a green tick for a directory we have never confirmed the assistant reads from. The orchestrator now stamps `Host_outcome.verified` from the registry for every host it reports, including one requested with --agent but not installed. This is a `--json` contract addition: each host object gains a boolean `verified`. The human report appends "(paths not yet verified)" to the line for such a host, and only where a claim about packs is actually being made. The README's Skills section gains a host coverage table saying which assistants are confirmed and what "not yet" means, linking REPLY-51268. Also here, because both are orchestrator-level: - An end-to-end `update` test through run_skills with one native and one flat host, asserting both answer "already at the target version" with `current` and that the "start a new session" advice does not fire. `update` had no coverage through the orchestrator at all. - A test whose title claimed the opposite of its body ("routes a native host through the flat adapter under --project", asserting that Claude Code stays native) now describes what it asserts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- README.md | 12 +++++ src/__tests__/skills/orchestrate.test.ts | 57 +++++++++++++++++++++++- src/__tests__/skills/report.test.ts | 19 ++++++++ src/skills/orchestrate.ts | 16 ++++--- src/skills/report.ts | 6 ++- src/skills/types.ts | 6 +++ 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8072c10..726ca8c 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,18 @@ 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. diff --git a/src/__tests__/skills/orchestrate.test.ts b/src/__tests__/skills/orchestrate.test.ts index 97c2195..5a6cdc7 100644 --- a/src/__tests__/skills/orchestrate.test.ts +++ b/src/__tests__/skills/orchestrate.test.ts @@ -3,6 +3,7 @@ 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'; @@ -109,7 +110,7 @@ describe('run_skills', ()=>{ await expect(run_skills(opts({requested: ['ghost']}))).rejects.toThrow(UsageError); }); - it('routes a native host through the flat adapter under --project', async()=>{ + 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. @@ -208,6 +209,60 @@ describe('run_skills', ()=>{ 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); + }); + + // 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'); diff --git a/src/__tests__/skills/report.test.ts b/src/__tests__/skills/report.test.ts index 0143077..0760d69 100644 --- a/src/__tests__/skills/report.test.ts +++ b/src/__tests__/skills/report.test.ts @@ -111,6 +111,25 @@ describe('human_lines', ()=>{ 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'); diff --git a/src/skills/orchestrate.ts b/src/skills/orchestrate.ts index 0538baf..9278a8a 100644 --- a/src/skills/orchestrate.ts +++ b/src/skills/orchestrate.ts @@ -6,7 +6,7 @@ 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_outcome, Operation, Pack, Report, Runner, Scope} from './types'; +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 @@ -62,9 +62,10 @@ const guard_remove = (packs: Pack[], all: Pack[]): void=>{ } }; -const not_detected = (id: string, label: string, kind: Host_outcome['kind']): Host_outcome=>({ - host: id, label, kind, status: 'skipped', reason: 'not-detected', - detail: `${label} was requested with --agent but is not installed on this machine`, +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=>{ @@ -120,11 +121,14 @@ const run_skills = async(opts: Skills_opts): Promise=>{ hint: `re-run \`reply skills ${opts.operation}\` once the underlying error for ${host.def.label} is resolved`, }; } - hosts.push(outcome); + // 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.id, def.label, def.kind)); + hosts.push(not_detected(def)); } // The commit is only known when this run itself cloned something: a flat diff --git a/src/skills/report.ts b/src/skills/report.ts index ccc5a29..7a8f65e 100644 --- a/src/skills/report.ts +++ b/src/skills/report.ts @@ -59,7 +59,11 @@ const host_line = (host: Host_outcome, report_action: Operation): string=>{ } const parts = [...groups.entries()].map(([action, names])=>`${names.join(', ')} ${action}`); const mark = host.status === 'ok' ? pc.green('✓') : pc.yellow('⚠'); - return `${mark} ${label}· ${parts.join('; ')}`; + // 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}`; }; const changed = (report: Report): boolean=>report.hosts.some(h=> diff --git a/src/skills/types.ts b/src/skills/types.ts index 647cd74..2d06895 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -79,6 +79,12 @@ type Host_outcome = { // 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'; From c3f886721c9581b3a2461e2d8433b0b4758ae686 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 19:39:30 -0700 Subject: [PATCH 31/32] fix(skills): advise a new session when a re-copy rewrote files at the same version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I3 made an unchanged version report `current` in every adapter, which is right, but the reporter decided "start a new session" from the action labels alone — so two cases that really did rewrite the user's files went silent: repairing an incomplete install, and an `update` that pulls a new commit on the same ref at an unchanged 0.1.0. Both used to print the advice through the wrong `upgraded 0.1.0 -> 0.1.0` label this wave removed. A regression introduced by 36ae3a7. "The version moved" and "the bytes moved" are two facts, and only the first survived. They are separated rather than recombined: the flat adapter already holds the clone commit next to `previous.commit` at the point it journals a copy, so a `current` outcome now carries `refreshed: true` when the commit differs or the previous entry was incomplete. The reporter's advice keys off that as well as the actions. This is a `--json` contract addition — `Pack_outcome` gains an optional boolean `refreshed`. No new `Pack_action` value: `current` still means "the version did not move", which is the rule as prescribed. The flag appears only on a `current` pack, since that is the only label that drops the information. A native host never sets it (it cannot see below its own plugin CLI) and neither does a dry run, which does not clone and so cannot know whether the ref moved. Tests: a same-version re-copy from a new commit reports `current` with `refreshed`, and the report still advises a new session, asserted both at the adapter and end-to-end through run_skills; the same commit re-copied does not set it; a dry run never guesses; and the repair case carries it too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/adapter-flat.test.ts | 43 +++++++++++++++++++++++ src/__tests__/skills/orchestrate.test.ts | 18 ++++++++++ src/__tests__/skills/report.test.ts | 11 ++++++ src/skills/adapter-flat.ts | 38 +++++++++++++++----- src/skills/report.ts | 8 ++++- src/skills/types.ts | 8 +++++ 6 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts index 20003d7..a6d6e50 100644 --- a/src/__tests__/skills/adapter-flat.test.ts +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -563,6 +563,10 @@ describe('run_flat incomplete installs', ()=>{ 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()=>{ @@ -615,6 +619,45 @@ describe('run_flat incomplete installs', ()=>{ }); }); +// 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 diff --git a/src/__tests__/skills/orchestrate.test.ts b/src/__tests__/skills/orchestrate.test.ts index 5a6cdc7..dc58ba1 100644 --- a/src/__tests__/skills/orchestrate.test.ts +++ b/src/__tests__/skills/orchestrate.test.ts @@ -247,6 +247,24 @@ describe('run_skills', ()=>{ 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()=>{ diff --git a/src/__tests__/skills/report.test.ts b/src/__tests__/skills/report.test.ts index 0760d69..d1280b1 100644 --- a/src/__tests__/skills/report.test.ts +++ b/src/__tests__/skills/report.test.ts @@ -145,6 +145,17 @@ describe('human_lines', ()=>{ 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'}], diff --git a/src/skills/adapter-flat.ts b/src/skills/adapter-flat.ts index d8b1535..18152b3 100644 --- a/src/skills/adapter-flat.ts +++ b/src/skills/adapter-flat.ts @@ -249,14 +249,36 @@ const kept_hint = (names: Iterable): string | undefined=>{ // 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. -const copied_outcome = (name: string, version: string, from?: string): Pack_outcome=>{ - if (from === undefined) +// +// 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, action: 'installed', version}; + return {name: pack_name, action: 'upgraded', version, from: previous.version}; } - return from === version - ? {name, action: 'current', version} - : {name, action: 'upgraded', version, from}; + 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 = { @@ -435,7 +457,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ { for (const pack of pending) { - outcomes.push(copied_outcome(pack.name, pack.version, entry_for(pack.name)?.version)); + outcomes.push(copied_outcome(pack.name, pack.version, entry_for(pack.name))); } return {...base, packs: outcomes}; } @@ -531,7 +553,7 @@ const run_flat = async(opts: Flat_opts): Promise=>{ complete: true, installed_at: new Date().toISOString(), }); - outcomes.push(copied_outcome(pack.name, pack.version, previous?.version)); + outcomes.push(copied_outcome(pack.name, pack.version, previous, cloned.commit)); } } catch (error) { // outcomes.length is not "something landed" — every entry pushed so diff --git a/src/skills/report.ts b/src/skills/report.ts index 7a8f65e..c919725 100644 --- a/src/skills/report.ts +++ b/src/skills/report.ts @@ -66,8 +66,14 @@ const host_line = (host: Host_outcome, report_action: Operation): string=>{ 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')); + (h.packs ?? []).some(p=> + p.action === 'installed' || p.action === 'upgraded' || p.refreshed === true)); const human_lines = (report: Report): string[]=>{ const lines: string[] = []; diff --git a/src/skills/types.ts b/src/skills/types.ts index 2d06895..a86e2ef 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -58,6 +58,14 @@ type Pack_outcome = { 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'; From 5fa402f1694617210a4430f55e86bc48fabb4b28 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Thu, 30 Jul 2026 20:07:22 -0700 Subject: [PATCH 32/32] fix: assert case sensitivity per platform, not Windows everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The differently-cased-journaled-path test asserted that the two spellings name one file. That is true on Windows and false on Linux, so CI was red on ubuntu and green on windows. The production code is correct on both: owns_dir and is_within share path.relative, whose case sensitivity deliberately follows the platform's filesystem. Split the test to assert the real behaviour on each — already ours where case is ignored, a foreign file that must not be overwritten where case distinguishes paths. The POSIX expectation is taken from the actual CI output, not inferred. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QmPs4sPmBd8zBKKGiFYxM4 --- src/__tests__/skills/adapter-flat.test.ts | 50 ++++++++++++++++------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/__tests__/skills/adapter-flat.test.ts b/src/__tests__/skills/adapter-flat.test.ts index a6d6e50..6f35177 100644 --- a/src/__tests__/skills/adapter-flat.test.ts +++ b/src/__tests__/skills/adapter-flat.test.ts @@ -688,16 +688,21 @@ describe('run_flat abort status', ()=>{ }); }); -// Minor, folded in for the same reason: owns_dir/protected_files compared -// paths case-sensitively while is_within does not, so a differently-cased -// path — routine on Windows, which CI runs — was recognised by one and not -// the other. Both must agree. -describe('run_flat case-insensitive path comparisons', ()=>{ - it('recognises a differently-cased journaled path as already ours, not a foreign collision', async()=>{ +// 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'); - // Same physical file as fake_clone will produce, but recorded with - // different case — as a differently-cased-but-equivalent path from a - // prior run might be, on a case-insensitive filesystem. 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', @@ -705,17 +710,32 @@ describe('run_flat case-insensitive path comparisons', ()=>{ }, 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(); - // `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 this test exercises. const outcome = await run_flat(flat_opts('update', core_only)); - // Re-copied from a fresh clone, but at the same version — so `current` - // (I3), and the file is proof the copy itself was not skipped. + // 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.existsSync(path.join(target, 'ai-sdr-core-skill', 'SKILL.md'))).toBe(true); 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'); + }); });