Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/registry-subcommand.md
Original file line number Diff line number Diff line change
@@ -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 <name> <path>` — registers a peer, validates its manifest, refuses on duplicate name
- `squad registry list` — shows all registered peers
- `squad registry remove <name>` — 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.
22 changes: 17 additions & 5 deletions .squad/skills/cross-squad/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ async function main(): Promise<void> {
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 <squad-name> <description>`);
console.log(` ${BOLD}registry${RESET} Manage peer squads for cross-squad discovery (no inheritance)`);
console.log(` Usage: registry add <name> <path>`);
console.log(` registry list`);
console.log(` registry remove <name>`);
Comment on lines +267 to +270
console.log(` ${BOLD}upstream${RESET} Manage upstream Squad sources`);
console.log(` Usage: upstream add <source> [--name <n>] [--ref <branch>]`);
console.log(` upstream remove <name>`);
Expand Down Expand Up @@ -1098,6 +1102,12 @@ async function main(): Promise<void> {
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));
Expand Down
87 changes: 85 additions & 2 deletions packages/squad-cli/src/cli/commands/cross-squad.ts
Original file line number Diff line number Diff line change
@@ -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 <squad-name> <description> — create work in another squad
* squad registry add <name> <path> — register a peer squad (no inheritance)
* squad registry list — show registered peer squads
* squad registry remove <name> — 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';
Expand All @@ -18,6 +23,9 @@ import {
formatDiscoveryTable,
findSquadByName,
buildDelegationArgs,
readSquadRegistry,
addRegistryEntry,
removeRegistryEntry,
type DiscoveredSquad,
} from '@bradygaster/squad-sdk';

Expand Down Expand Up @@ -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<void> {
const action = args[0];
if (!action || (action !== 'add' && action !== 'list' && action !== 'remove')) {
fatal('Usage: squad registry add <name> <path> | list | remove <name>');
}

const squadDirInfo = detectSquadDir(process.cwd());
const squadDir = squadDirInfo.path;

Comment on lines +124 to +126
if (action === 'list') {
const entries = readSquadRegistry(squadDir);
if (entries.length === 0) {
info(`${DIM}No peer squads registered. Add one with: squad registry add <name> <path>${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 <name> <path>');
}
// 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 <name>');
}
const removed = removeRegistryEntry(squadDir, name);
if (removed) {
success(`Removed peer squad "${name}" from the registry.`);
} else {
warn(`No peer squad named "${name}" was registered.`);
}
}
}
22 changes: 17 additions & 5 deletions packages/squad-cli/templates/skills/cross-squad/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions packages/squad-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 107 additions & 2 deletions packages/squad-sdk/src/runtime/cross-squad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +130 to +133
const manifestPath = join(root, '.squad', 'manifest.json');
if (!storage.existsSync(manifestPath)) return null;
try {
const raw = storage.readSync(manifestPath) ?? '';
Expand Down Expand Up @@ -250,6 +262,99 @@ export function discoverSquads(squadDir: string): DiscoveredSquad[] {
return merged;
}

// ============================================================================
// Registry CRUD — manage .squad/squad-registry.json
// ============================================================================
Comment on lines +265 to +267

/** 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
// ============================================================================
Expand Down Expand Up @@ -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 <name> <path>", or an upstream with "squad upstream add".';
}

const lines: string[] = ['\nDiscovered squads:\n'];
Expand Down
Loading
Loading