diff --git a/.changeset/registry-subcommand.md b/.changeset/registry-subcommand.md new file mode 100644 index 000000000..08be83809 --- /dev/null +++ b/.changeset/registry-subcommand.md @@ -0,0 +1,20 @@ +--- +"@bradygaster/squad-cli": minor +"@bradygaster/squad-sdk": minor +--- + +Add `squad registry add/list/remove` for discovery-only peer squads (no inheritance) + +The existing `squad upstream add` triggers inheritance (skills/decisions/wisdom flow from the other squad into yours at session start). For peer relationships where you want discovery and delegation but NOT inheritance, you previously had to hand-edit `.squad/squad-registry.json` — the `squad discover` error message even told you to "create a squad-registry.json" manually. + +This adds a real CLI surface for the registry, symmetric to `squad upstream`: + +- `squad registry add ` — registers a peer, validates its manifest, refuses on duplicate name +- `squad registry list` — shows all registered peers +- `squad registry remove ` — removes by name + +It also fixes a subtle path-semantics confusion: `readManifest()` now accepts BOTH `repo-root` and `repo-root/.squad` paths (the docs/SKILL showed the `.squad`-suffixed form, but the code previously joined `.squad/manifest.json` onto whatever you gave it, so the suffixed form silently failed). The SDK helpers (`readSquadRegistry`, `writeSquadRegistry`, `addRegistryEntry`, `removeRegistryEntry`) are exported for tooling. + +The `cross-squad` SKILL and the `squad discover` empty-state hint are updated to reflect the new command. + +Closes #1290. diff --git a/.squad/skills/cross-squad/SKILL.md b/.squad/skills/cross-squad/SKILL.md index 1d4e3a251..dd400632b 100644 --- a/.squad/skills/cross-squad/SKILL.md +++ b/.squad/skills/cross-squad/SKILL.md @@ -25,10 +25,12 @@ Cross-squad orchestration applies when: ## Patterns ### Discovery via Manifest -Each squad publishes a `.squad/manifest.json` declaring its name, capabilities, and contact information. Squads discover each other through: -1. **Well-known paths**: Check `.squad/manifest.json` in known org repos -2. **Upstream config**: Squads already listed in `.squad/upstream.json` are checked for manifests -3. **Explicit registry**: A central `squad-registry.json` can list all squads in an org +Each squad publishes a `.squad/manifest.json` declaring its name, capabilities, and contact information. Squads discover each other through two mechanisms: + +1. **`.squad/squad-registry.json`** — **discovery-only.** Peer squads are findable via `squad discover` and addressable via `squad delegate`, but their skills/decisions/wisdom are NOT loaded into your coordinator. Manage with `squad registry add/list/remove`. +2. **`.squad/upstream.json`** — **discovery + inheritance.** Squads listed here are also discoverable, AND your coordinator inherits their skills/decisions/wisdom/routing at session start. Manage with `squad upstream add/list/remove/sync`. + +Both forms read the peer's manifest via the same code path. The `path` field is the **repository root** (e.g. `../friend-repo`), and Squad appends `.squad/manifest.json` internally. Pointing at the `.squad/` directory works too — Squad accepts both forms (`readManifest` strips a trailing `.squad` if present). ```json { @@ -73,9 +75,19 @@ Track delegated work completion: ## Examples +### Registering a peer squad (no inheritance) +```bash +# Friend's repo is checked out at ../friend-platform/ +squad registry add platform-squad ../friend-platform + +# Verify +squad registry list +squad discover +``` + ### Discovering squads ```bash -# List all squads discoverable from upstreams and known repos +# List all squads discoverable from registry + upstreams squad discover # Output: diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 6dc4d2a73..3832aeb69 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -264,6 +264,10 @@ async function main(): Promise { console.log(` ${BOLD}discover${RESET} List known squads and their capabilities`); console.log(` ${BOLD}delegate${RESET} Create work in another squad`); console.log(` Usage: delegate `); + console.log(` ${BOLD}registry${RESET} Manage peer squads for cross-squad discovery (no inheritance)`); + console.log(` Usage: registry add `); + console.log(` registry list`); + console.log(` registry remove `); console.log(` ${BOLD}upstream${RESET} Manage upstream Squad sources`); console.log(` Usage: upstream add [--name ] [--ref ]`); console.log(` upstream remove `); @@ -1098,6 +1102,12 @@ async function main(): Promise { return; } + if (cmd === 'registry') { + const { registryCommand } = await import('./cli/commands/cross-squad.js'); + await registryCommand(args.slice(1)); + return; + } + if (cmd === 'economy') { const { runEconomy } = await import('./cli/commands/economy.js'); await runEconomy(getSquadStartDir(), args.slice(1)); diff --git a/packages/squad-cli/src/cli/commands/cross-squad.ts b/packages/squad-cli/src/cli/commands/cross-squad.ts index 6123beda5..78cd9ac23 100644 --- a/packages/squad-cli/src/cli/commands/cross-squad.ts +++ b/packages/squad-cli/src/cli/commands/cross-squad.ts @@ -1,15 +1,20 @@ /** - * squad discover / squad delegate — CLI commands for cross-squad orchestration. + * squad discover / squad delegate / squad registry — CLI commands for + * cross-squad orchestration. * * Commands: - * squad discover — list known squads and capabilities + * squad discover — list known squads and capabilities * squad delegate — create work in another squad + * squad registry add — register a peer squad (no inheritance) + * squad registry list — show registered peer squads + * squad registry remove — remove a registered peer squad * * @module cli/commands/cross-squad */ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { resolve as resolvePath } from 'node:path'; import { success, warn, info, BOLD, RESET, DIM } from '../core/output.js'; import { fatal } from '../core/errors.js'; import { detectSquadDir } from '../core/detect-squad-dir.js'; @@ -18,6 +23,9 @@ import { formatDiscoveryTable, findSquadByName, buildDelegationArgs, + readSquadRegistry, + addRegistryEntry, + removeRegistryEntry, type DiscoveredSquad, } from '@bradygaster/squad-sdk'; @@ -98,3 +106,78 @@ function buildDelegationBody(description: string, target: DiscoveredSquad): stri `${DIM}Created by squad cross-squad orchestration${RESET}`, ].join('\n'); } + +/** + * `squad registry` — manage peer squads in `.squad/squad-registry.json`. + * + * Unlike `squad upstream add`, registry entries are discovery-only — peer + * squads are findable via `squad discover` and addressable via `squad + * delegate`, but their skills/decisions/wisdom are NOT inherited by your + * coordinator at session start. + */ +export async function registryCommand(args: string[]): Promise { + const action = args[0]; + if (!action || (action !== 'add' && action !== 'list' && action !== 'remove')) { + fatal('Usage: squad registry add | list | remove '); + } + + const squadDirInfo = detectSquadDir(process.cwd()); + const squadDir = squadDirInfo.path; + + if (action === 'list') { + const entries = readSquadRegistry(squadDir); + if (entries.length === 0) { + info(`${DIM}No peer squads registered. Add one with: squad registry add ${RESET}`); + return; + } + info(`\n${BOLD}Registered peer squads${RESET} (.squad/squad-registry.json):\n`); + for (const entry of entries) { + info(` ${BOLD}${entry.name}${RESET} → ${entry.path}`); + } + info(''); + return; + } + + if (action === 'add') { + const name = args[1]; + const rawPath = args[2]; + if (!name || !rawPath) { + fatal('Usage: squad registry add '); + } + // Resolve to absolute path so the registry is portable wrt cwd at write time + const absPath = resolvePath(rawPath); + const result = addRegistryEntry(squadDir, name, absPath); + if (result.added) { + success(`Registered peer squad "${name}" → ${absPath}`); + info(` Capabilities: ${result.manifest!.capabilities.join(', ')}`); + info(` Repo: ${result.manifest!.contact.repo}`); + info(` Accepts: ${result.manifest!.accepts.join(', ')}`); + info(`\n${DIM}Run "squad discover" to see all registered peers.${RESET}`); + return; + } + if (result.reason === 'duplicate-name') { + fatal(`A peer named "${name}" is already registered. Remove it first: squad registry remove ${name}`); + } + if (result.reason === 'invalid-manifest') { + fatal( + `No valid .squad/manifest.json found at ${absPath}.\n` + + ` Expected: ${absPath}/.squad/manifest.json (or ${absPath}/manifest.json if you pointed at .squad/).\n` + + ` Ask the peer team to publish a manifest, or verify the path resolves locally.`, + ); + } + fatal('Registry add failed for an unknown reason.'); + } + + if (action === 'remove') { + const name = args[1]; + if (!name) { + fatal('Usage: squad registry remove '); + } + const removed = removeRegistryEntry(squadDir, name); + if (removed) { + success(`Removed peer squad "${name}" from the registry.`); + } else { + warn(`No peer squad named "${name}" was registered.`); + } + } +} diff --git a/packages/squad-cli/templates/skills/cross-squad/SKILL.md b/packages/squad-cli/templates/skills/cross-squad/SKILL.md index 1d4e3a251..dd400632b 100644 --- a/packages/squad-cli/templates/skills/cross-squad/SKILL.md +++ b/packages/squad-cli/templates/skills/cross-squad/SKILL.md @@ -25,10 +25,12 @@ Cross-squad orchestration applies when: ## Patterns ### Discovery via Manifest -Each squad publishes a `.squad/manifest.json` declaring its name, capabilities, and contact information. Squads discover each other through: -1. **Well-known paths**: Check `.squad/manifest.json` in known org repos -2. **Upstream config**: Squads already listed in `.squad/upstream.json` are checked for manifests -3. **Explicit registry**: A central `squad-registry.json` can list all squads in an org +Each squad publishes a `.squad/manifest.json` declaring its name, capabilities, and contact information. Squads discover each other through two mechanisms: + +1. **`.squad/squad-registry.json`** — **discovery-only.** Peer squads are findable via `squad discover` and addressable via `squad delegate`, but their skills/decisions/wisdom are NOT loaded into your coordinator. Manage with `squad registry add/list/remove`. +2. **`.squad/upstream.json`** — **discovery + inheritance.** Squads listed here are also discoverable, AND your coordinator inherits their skills/decisions/wisdom/routing at session start. Manage with `squad upstream add/list/remove/sync`. + +Both forms read the peer's manifest via the same code path. The `path` field is the **repository root** (e.g. `../friend-repo`), and Squad appends `.squad/manifest.json` internally. Pointing at the `.squad/` directory works too — Squad accepts both forms (`readManifest` strips a trailing `.squad` if present). ```json { @@ -73,9 +75,19 @@ Track delegated work completion: ## Examples +### Registering a peer squad (no inheritance) +```bash +# Friend's repo is checked out at ../friend-platform/ +squad registry add platform-squad ../friend-platform + +# Verify +squad registry list +squad discover +``` + ### Discovering squads ```bash -# List all squads discoverable from upstreams and known repos +# List all squads discoverable from registry + upstreams squad discover # Output: diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 3833c1fb4..6f1473237 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -44,11 +44,17 @@ export { type CrossSquadIssueOptions, type CrossSquadIssueResult, type CrossSquadWorkStatus, + type RegistryEntry, + type AddRegistryEntryResult, validateManifest, readManifest, discoverSquads, discoverFromUpstreams, discoverFromRegistry, + readSquadRegistry, + writeSquadRegistry, + addRegistryEntry, + removeRegistryEntry, buildDelegationArgs, buildStatusCheckArgs, parseIssueStatus, diff --git a/packages/squad-sdk/src/runtime/cross-squad.ts b/packages/squad-sdk/src/runtime/cross-squad.ts index 755bb44f2..86b544c8a 100644 --- a/packages/squad-sdk/src/runtime/cross-squad.ts +++ b/packages/squad-sdk/src/runtime/cross-squad.ts @@ -116,10 +116,22 @@ export function validateManifest(data: unknown): data is SquadManifest { /** * Read and parse a squad manifest from a directory path. + * * Looks for `.squad/manifest.json` relative to the given root. + * + * **Dual-path acceptance:** If `repoPath` already ends in `.squad` (or + * `.squad/` / `.squad\`), the trailing segment is stripped before the + * lookup so both the repo root AND the `.squad` directory work as input. + * This matches how users naturally describe a squad in registry/upstream + * configuration ("point me at their `.squad/` dir" vs "point me at their + * repo root") without forcing one form. */ export function readManifest(repoPath: string): SquadManifest | null { - const manifestPath = join(repoPath, '.squad', 'manifest.json'); + const normalized = repoPath.replace(/[/\\]$/, ''); + const root = normalized.endsWith('.squad') || normalized.endsWith('/.squad') || normalized.endsWith('\\.squad') + ? normalized.slice(0, normalized.length - '.squad'.length).replace(/[/\\]$/, '') + : normalized; + const manifestPath = join(root, '.squad', 'manifest.json'); if (!storage.existsSync(manifestPath)) return null; try { const raw = storage.readSync(manifestPath) ?? ''; @@ -250,6 +262,99 @@ export function discoverSquads(squadDir: string): DiscoveredSquad[] { return merged; } +// ============================================================================ +// Registry CRUD — manage .squad/squad-registry.json +// ============================================================================ + +/** A single entry in `.squad/squad-registry.json`. */ +export interface RegistryEntry { + /** Identifier within this repo's registry. Unique. */ + name: string; + /** Filesystem path to the peer squad (repo root OR its .squad dir). */ + path: string; +} + +/** + * Read `.squad/squad-registry.json` and return its entries. + * Returns an empty array if the file does not exist or is malformed. + */ +export function readSquadRegistry(squadDir: string): RegistryEntry[] { + const registryPath = join(squadDir, 'squad-registry.json'); + if (!storage.existsSync(registryPath)) return []; + try { + const raw = storage.readSync(registryPath) ?? ''; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((e): e is RegistryEntry => + typeof e === 'object' && e !== null && + typeof (e as RegistryEntry).name === 'string' && + typeof (e as RegistryEntry).path === 'string', + ); + } catch { + return []; + } +} + +/** + * Overwrite `.squad/squad-registry.json` with the given entries. + * Creates the file (and `.squad/` directory) if missing. + */ +export function writeSquadRegistry(squadDir: string, entries: RegistryEntry[]): void { + if (!storage.existsSync(squadDir)) { + storage.mkdirSync(squadDir, { recursive: true }); + } + const registryPath = join(squadDir, 'squad-registry.json'); + storage.writeSync(registryPath, JSON.stringify(entries, null, 2) + '\n'); +} + +/** Result of `addRegistryEntry`. */ +export interface AddRegistryEntryResult { + /** True if a new entry was written. */ + added: boolean; + /** Reason if not added: 'duplicate-name' | 'invalid-manifest'. */ + reason?: 'duplicate-name' | 'invalid-manifest'; + /** The manifest validated against the path (when added). */ + manifest?: SquadManifest; +} + +/** + * Add a peer squad to `.squad/squad-registry.json`. + * + * Validates that the path resolves to a readable manifest before writing. + * Refuses on duplicate name. Accepts both repo-root and `.squad`-suffixed + * paths (see `readManifest`). + */ +export function addRegistryEntry( + squadDir: string, + name: string, + path: string, +): AddRegistryEntryResult { + const existing = readSquadRegistry(squadDir); + if (existing.some(e => e.name === name)) { + return { added: false, reason: 'duplicate-name' }; + } + const manifest = readManifest(path); + if (!manifest) { + return { added: false, reason: 'invalid-manifest' }; + } + const next = [...existing, { name, path }]; + writeSquadRegistry(squadDir, next); + return { added: true, manifest }; +} + +/** + * Remove an entry from `.squad/squad-registry.json` by name. + * Returns true if an entry was removed, false if no matching name was found. + */ +export function removeRegistryEntry(squadDir: string, name: string): boolean { + const existing = readSquadRegistry(squadDir); + const next = existing.filter(e => e.name !== name); + if (next.length === existing.length) return false; + writeSquadRegistry(squadDir, next); + return true; +} + // ============================================================================ // Delegation // ============================================================================ @@ -317,7 +422,7 @@ export function parseIssueStatus(jsonOutput: string, issueUrl: string): CrossSqu /** Format discovered squads for terminal display. */ export function formatDiscoveryTable(squads: DiscoveredSquad[]): string { if (squads.length === 0) { - return 'No squads discovered. Add upstreams with "squad upstream add" or create a squad-registry.json.'; + return 'No squads discovered. Add a peer with "squad registry add ", or an upstream with "squad upstream add".'; } const lines: string[] = ['\nDiscovered squads:\n']; diff --git a/packages/squad-sdk/templates/skills/cross-squad/SKILL.md b/packages/squad-sdk/templates/skills/cross-squad/SKILL.md index 1d4e3a251..dd400632b 100644 --- a/packages/squad-sdk/templates/skills/cross-squad/SKILL.md +++ b/packages/squad-sdk/templates/skills/cross-squad/SKILL.md @@ -25,10 +25,12 @@ Cross-squad orchestration applies when: ## Patterns ### Discovery via Manifest -Each squad publishes a `.squad/manifest.json` declaring its name, capabilities, and contact information. Squads discover each other through: -1. **Well-known paths**: Check `.squad/manifest.json` in known org repos -2. **Upstream config**: Squads already listed in `.squad/upstream.json` are checked for manifests -3. **Explicit registry**: A central `squad-registry.json` can list all squads in an org +Each squad publishes a `.squad/manifest.json` declaring its name, capabilities, and contact information. Squads discover each other through two mechanisms: + +1. **`.squad/squad-registry.json`** — **discovery-only.** Peer squads are findable via `squad discover` and addressable via `squad delegate`, but their skills/decisions/wisdom are NOT loaded into your coordinator. Manage with `squad registry add/list/remove`. +2. **`.squad/upstream.json`** — **discovery + inheritance.** Squads listed here are also discoverable, AND your coordinator inherits their skills/decisions/wisdom/routing at session start. Manage with `squad upstream add/list/remove/sync`. + +Both forms read the peer's manifest via the same code path. The `path` field is the **repository root** (e.g. `../friend-repo`), and Squad appends `.squad/manifest.json` internally. Pointing at the `.squad/` directory works too — Squad accepts both forms (`readManifest` strips a trailing `.squad` if present). ```json { @@ -73,9 +75,19 @@ Track delegated work completion: ## Examples +### Registering a peer squad (no inheritance) +```bash +# Friend's repo is checked out at ../friend-platform/ +squad registry add platform-squad ../friend-platform + +# Verify +squad registry list +squad discover +``` + ### Discovering squads ```bash -# List all squads discoverable from upstreams and known repos +# List all squads discoverable from registry + upstreams squad discover # Output: diff --git a/test/cross-squad-registry.test.ts b/test/cross-squad-registry.test.ts new file mode 100644 index 000000000..f783f85da --- /dev/null +++ b/test/cross-squad-registry.test.ts @@ -0,0 +1,266 @@ +/** + * Tests for the registry CRUD helpers and the dual-path readManifest fix. + * + * Covers: + * - readManifest accepts both repo-root and repo-root/.squad paths + * - readSquadRegistry / writeSquadRegistry round-trip + * - addRegistryEntry: success, duplicate-name rejection, invalid-manifest rejection + * - removeRegistryEntry: returns true/false correctly + * - Entries written by addRegistryEntry are picked up by discoverSquads + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + readManifest, + readSquadRegistry, + writeSquadRegistry, + addRegistryEntry, + removeRegistryEntry, + discoverSquads, +} from '../packages/squad-sdk/src/runtime/cross-squad.js'; + +const VALID_MANIFEST = { + name: 'peer-squad', + capabilities: ['kubernetes'], + contact: { repo: 'org/peer' }, + accepts: ['issues'], +} as const; + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanDir(dir: string): void { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +function writePeerManifest(repoDir: string, manifest: object = VALID_MANIFEST): void { + const squadDir = path.join(repoDir, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); +} + +// ============================================================================ +// readManifest dual-path +// ============================================================================ + +describe('readManifest — dual-path acceptance', () => { + let peer: string; + + beforeEach(() => { + peer = makeTempDir('peer-'); + writePeerManifest(peer); + }); + + afterEach(() => cleanDir(peer)); + + it('accepts the repo root path', () => { + const m = readManifest(peer); + expect(m).not.toBeNull(); + expect(m!.name).toBe('peer-squad'); + }); + + it('accepts a path with trailing .squad', () => { + const m = readManifest(path.join(peer, '.squad')); + expect(m).not.toBeNull(); + expect(m!.name).toBe('peer-squad'); + }); + + it('accepts a path with trailing .squad/', () => { + const m = readManifest(path.join(peer, '.squad') + path.sep); + expect(m).not.toBeNull(); + }); + + it('returns null when no manifest is present', () => { + const empty = makeTempDir('empty-'); + try { + expect(readManifest(empty)).toBeNull(); + } finally { + cleanDir(empty); + } + }); +}); + +// ============================================================================ +// Registry CRUD +// ============================================================================ + +describe('readSquadRegistry / writeSquadRegistry', () => { + let dir: string; + + beforeEach(() => { + dir = makeTempDir('me-'); + fs.mkdirSync(path.join(dir, '.squad'), { recursive: true }); + }); + + afterEach(() => cleanDir(dir)); + + it('returns empty array when no registry file exists', () => { + expect(readSquadRegistry(path.join(dir, '.squad'))).toEqual([]); + }); + + it('round-trips entries', () => { + const entries = [ + { name: 'a', path: '/tmp/a' }, + { name: 'b', path: '/tmp/b' }, + ]; + writeSquadRegistry(path.join(dir, '.squad'), entries); + expect(readSquadRegistry(path.join(dir, '.squad'))).toEqual(entries); + }); + + it('returns empty on malformed JSON', () => { + const reg = path.join(dir, '.squad', 'squad-registry.json'); + fs.writeFileSync(reg, '{ not json'); + expect(readSquadRegistry(path.join(dir, '.squad'))).toEqual([]); + }); + + it('returns empty when JSON is not an array', () => { + const reg = path.join(dir, '.squad', 'squad-registry.json'); + fs.writeFileSync(reg, '{"name":"a","path":"/tmp"}'); + expect(readSquadRegistry(path.join(dir, '.squad'))).toEqual([]); + }); + + it('filters out malformed entries', () => { + const reg = path.join(dir, '.squad', 'squad-registry.json'); + fs.writeFileSync(reg, JSON.stringify([ + { name: 'good', path: '/tmp/g' }, + { name: 42, path: '/tmp/bad' }, + { name: 'no-path' }, + null, + { name: 'ok', path: '/tmp/ok' }, + ])); + const entries = readSquadRegistry(path.join(dir, '.squad')); + expect(entries.map(e => e.name).sort()).toEqual(['good', 'ok']); + }); + + it('creates .squad dir on write if missing', () => { + const fresh = makeTempDir('fresh-'); + try { + const squadDir = path.join(fresh, '.squad'); + writeSquadRegistry(squadDir, [{ name: 'x', path: '/tmp/x' }]); + expect(fs.existsSync(path.join(squadDir, 'squad-registry.json'))).toBe(true); + } finally { + cleanDir(fresh); + } + }); +}); + +describe('addRegistryEntry', () => { + let me: string; + let peer: string; + let mySquadDir: string; + + beforeEach(() => { + me = makeTempDir('me-'); + peer = makeTempDir('peer-'); + mySquadDir = path.join(me, '.squad'); + fs.mkdirSync(mySquadDir, { recursive: true }); + writePeerManifest(peer); + }); + + afterEach(() => { + cleanDir(me); + cleanDir(peer); + }); + + it('writes the entry on success', () => { + const result = addRegistryEntry(mySquadDir, 'friend', peer); + expect(result.added).toBe(true); + expect(result.manifest?.name).toBe('peer-squad'); + expect(readSquadRegistry(mySquadDir)).toEqual([{ name: 'friend', path: peer }]); + }); + + it('refuses on duplicate name', () => { + addRegistryEntry(mySquadDir, 'friend', peer); + const second = addRegistryEntry(mySquadDir, 'friend', peer); + expect(second.added).toBe(false); + expect(second.reason).toBe('duplicate-name'); + expect(readSquadRegistry(mySquadDir).length).toBe(1); + }); + + it('refuses when path has no manifest', () => { + const noManifest = makeTempDir('no-manifest-'); + try { + const result = addRegistryEntry(mySquadDir, 'broken', noManifest); + expect(result.added).toBe(false); + expect(result.reason).toBe('invalid-manifest'); + expect(readSquadRegistry(mySquadDir)).toEqual([]); + } finally { + cleanDir(noManifest); + } + }); + + it('accepts .squad-suffixed paths', () => { + const result = addRegistryEntry(mySquadDir, 'friend', path.join(peer, '.squad')); + expect(result.added).toBe(true); + }); +}); + +describe('removeRegistryEntry', () => { + let me: string; + let mySquadDir: string; + + beforeEach(() => { + me = makeTempDir('me-'); + mySquadDir = path.join(me, '.squad'); + fs.mkdirSync(mySquadDir, { recursive: true }); + writeSquadRegistry(mySquadDir, [ + { name: 'a', path: '/tmp/a' }, + { name: 'b', path: '/tmp/b' }, + ]); + }); + + afterEach(() => cleanDir(me)); + + it('returns true and removes when name exists', () => { + expect(removeRegistryEntry(mySquadDir, 'a')).toBe(true); + expect(readSquadRegistry(mySquadDir)).toEqual([{ name: 'b', path: '/tmp/b' }]); + }); + + it('returns false when name does not exist', () => { + expect(removeRegistryEntry(mySquadDir, 'missing')).toBe(false); + expect(readSquadRegistry(mySquadDir).length).toBe(2); + }); +}); + +// ============================================================================ +// End-to-end — addRegistryEntry feeds discoverSquads +// ============================================================================ + +describe('registry → discoverSquads integration', () => { + let me: string; + let peer: string; + let mySquadDir: string; + + beforeEach(() => { + me = makeTempDir('me-'); + peer = makeTempDir('peer-'); + mySquadDir = path.join(me, '.squad'); + fs.mkdirSync(mySquadDir, { recursive: true }); + writePeerManifest(peer); + }); + + afterEach(() => { + cleanDir(me); + cleanDir(peer); + }); + + it('addRegistryEntry makes the peer discoverable', () => { + addRegistryEntry(mySquadDir, 'friend', peer); + const discovered = discoverSquads(mySquadDir); + expect(discovered.length).toBe(1); + expect(discovered[0]?.manifest.name).toBe('peer-squad'); + expect(discovered[0]?.source).toBe('registry'); + expect(discovered[0]?.sourceRef).toBe('friend'); + }); + + it('removeRegistryEntry makes the peer un-discoverable', () => { + addRegistryEntry(mySquadDir, 'friend', peer); + expect(discoverSquads(mySquadDir).length).toBe(1); + removeRegistryEntry(mySquadDir, 'friend'); + expect(discoverSquads(mySquadDir).length).toBe(0); + }); +});