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
2 changes: 1 addition & 1 deletion .changeset/squad-2-0-multi-host-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"@bradygaster/squad-cli": major
---

Squad 2.0: decoupled backend and multi-host client adapters. Add `squad init --client` and `squad upgrade --client` (copilot, claude, kiro, opencode, cursor, codex, antigravity) with additive, non-clobbering config merges, persisted in `.squad/config.json` `clients[]`; add the `HostProjection` / spec `ClientAdapter` SDK surface with canonical capability tokens under `@bradygaster/squad-sdk/client` (and the deprecated `/clients` alias), and a host-agnostic coordinator prompt with per-host dispatch.
Squad 2.0: decoupled backend and multi-host client adapters. Add `squad init --client` and `squad upgrade --client` (copilot, claude, kiro, opencode, cursor, codex, antigravity) with additive, non-clobbering config merges, persisted in `.squad/config.json` `clients[]`; add the `HostProjection` / spec `ClientAdapter` SDK surface with canonical capability tokens under `@bradygaster/squad-sdk/client` (and the deprecated `/clients` alias), a host-agnostic coordinator prompt with per-host dispatch, and locked/transactional repair and `team.md` backfill for handwritten `.squad/casting/registry.json` files during `squad upgrade` (including `--dry-run`). The repair is per-record (valid records and their extra fields are preserved verbatim, the registry revision is bumped), archives the pre-repair originals under `.squad/casting/handwritten-archive-<sha256>/` by default, fails closed with a per-entry `dropped` list instead of silently discarding entries, derives backfilled agent ids from the `team.md` Charter column / `.squad/agents/*/charter.md` folders rather than from display names, reconciles a single Init Mode `history.json` snapshot, and never rolls a durable commit back.

Projection details: the Codex coordinator is emitted as a TOML custom agent (`.codex/agents/squad.toml` with `name`, `description`, `developer_instructions`); `.gitignore` entries are plain lines under a `# squad:<id>` comment (uninstall removes only those); merged files keep their existing line endings, JSON indentation and trailing-newline convention and are written atomically; OpenCode targets `opencode.jsonc` when it is the only config and refuses to clobber a JSONC file that contains comments; symlinked/junctioned ancestors of the repo root are accepted while symlinks escaping the root are still refused; a newly requested client whose host config could not be merged is reported but not persisted; `squad upgrade --dry-run --client <list>` previews the clients it would project.
19 changes: 17 additions & 2 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ export function detectParentGitRepo(dest: string): string | null {
return null;
}

/**
* True only when a human can actually answer a prompt: stdin AND stdout are TTYs and the run
* is not CI / explicitly non-interactive. squad init must never block on stdin otherwise
* (headless model runs, pipes, redirected output).
*/
export function isInteractiveSession(
env: NodeJS.ProcessEnv = process.env,
stdin: { isTTY?: boolean } = process.stdin,
stdout: { isTTY?: boolean } = process.stdout,
): boolean {
if (!stdin.isTTY || !stdout.isTTY) return false;
const off = (v: string | undefined): boolean => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
return !off(env['CI']) && !off(env['SQUAD_NONINTERACTIVE']);
}

/** True when animations should be suppressed (NO_COLOR, dumb term, non-TTY). */
export function isInitNoColor(): boolean {
return (
Expand Down Expand Up @@ -226,7 +241,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
console.log();

let useShared = true;
if (process.stdin.isTTY) {
if (isInteractiveSession()) {
const { createInterface } = await import('node:readline/promises');
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
Expand Down Expand Up @@ -539,7 +554,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
// to the team roster, allowing squad-labeled issues to be auto-assigned.
const teamContent = readTeamMd(squadDir);
if (!hasCopilot(teamContent)) {
if (process.stdin.isTTY) {
if (isInteractiveSession()) {
const { createInterface } = await import('node:readline/promises');
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
Expand Down
65 changes: 59 additions & 6 deletions packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { FSStorageProvider, stripTeamCapabilitiesBlock, syncTeamCapabilities } from '@bradygaster/squad-sdk';
import { ensureCastingRegistryPair, preflightCastingRegistryPair } from '@bradygaster/squad-sdk/casting';
import {
backfillCastingRegistryFromTeam,
ensureCastingRegistryPair,
preflightCastingRegistryPair,
repairHandwrittenCastingRegistry,
type RepairResult,
} from '@bradygaster/squad-sdk/casting';
import { success, warn, info, dim, bold } from './output.js';
import { fatal } from './errors.js';
import { detectSquadDir } from './detect-squad-dir.js';
Expand Down Expand Up @@ -652,6 +658,15 @@ export function ensureCastingDefaults(dest: string, templatesDir?: string): stri
const created: string[] = [];
try {
storage.mkdirSync(castingDir, { recursive: true });
// LLM-handwritten registries (role keys, agents arrays, no schema) are normalized instead of crashing upgrade;
// the repair itself decides whether existing history.json content blocks it
const repair = repairHandwrittenCastingRegistry(castingDir);
if (repair.repaired) {
warn('Normalized a handwritten .squad/casting/registry.json');
created.push(...repair.files.map((f) => `.squad/casting/${f}`));
} else {
warnCastingRepairSkipped('Could not normalize handwritten .squad/casting/registry.json', repair);
}
const pair = ensureCastingRegistryPair(
castingDir,
templateContent(
Expand All @@ -667,6 +682,14 @@ export function ensureCastingDefaults(dest: string, templatesDir?: string): stri
if (pair.created) {
created.push('.squad/casting/registry.json', '.squad/casting/history.json');
}
// team.md + charters written directly by a model on an older CLI leave the registry empty: backfill it from the roster
const backfill = backfillCastingRegistryFromTeam(castingDir, path.join(dest, '.squad', 'team.md'));
if (backfill.repaired) {
warn('Backfilled .squad/casting/registry.json from .squad/team.md');
created.push(...backfill.files.map((f) => `.squad/casting/${f}`).filter((f) => !created.includes(f)));
} else {
warnCastingRepairSkipped('Could not backfill .squad/casting/registry.json from .squad/team.md', backfill);
}

const policyPath = path.join(castingDir, 'policy.json');
if (!storage.existsSync(policyPath)) {
Expand Down Expand Up @@ -1213,6 +1236,15 @@ async function refreshClients(dest: string, squadDir: string, options: UpgradeOp
}
}

/** Surface why a handwritten-registry repair or team.md backfill declined to act, including per-entry drops. */
function warnCastingRepairSkipped(headline: string, result: RepairResult): void {
if (result.repaired || !result.reason) return;
warn(`${headline}: ${result.reason}`);
for (const { name, reason } of result.dropped ?? []) {
dim(` - ${name}: ${reason}`);
}
}

/**
* Run the upgrade command
*/
Expand Down Expand Up @@ -1245,19 +1277,40 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr

const projectType = detectProjectType(dest);

const castingMigrated = preflightCastingRegistryPair(
path.join(squadDirInfo.path, 'casting'),
options.dryRun ?? false,
);
// A handwritten registry (model wrote registry.json itself over the empty init pair) is repaired BEFORE the
// strict preflight, which would otherwise reject it and make the repair in ensureCastingDefaults unreachable.
// The repair reconciles a single Init Mode history snapshot; registries with real history keep failing closed.
const castingDir = path.join(squadDirInfo.path, 'casting');
const dryRun = options.dryRun ?? false;
const repair = repairHandwrittenCastingRegistry(castingDir, { dryRun });
const handwrittenRepaired = repair.repaired;
if (handwrittenRepaired) {
if (dryRun) {
info('Would normalize handwritten .squad/casting/registry.json to a managed registry/history pair.');
} else {
warn('Normalized a handwritten .squad/casting/registry.json');
}
} else {
warnCastingRepairSkipped('Could not normalize handwritten .squad/casting/registry.json', repair);
}
const castingMigrated = handwrittenRepaired && dryRun
? false
: preflightCastingRegistryPair(castingDir, dryRun);
if (castingMigrated) {
info(options.dryRun
info(dryRun
? 'Would migrate legacy casting state to a managed registry/history pair.'
: 'Migrated legacy casting state to a managed registry/history pair.');
}

// --dry-run: preview what upgrade would do without writing (#1052)
if (options.dryRun) {
info(`\n${bold('Dry run')} — previewing upgrade from ${oldVersion || 'unknown'} → ${cliVersion}\n`);
const backfill = backfillCastingRegistryFromTeam(castingDir, path.join(squadDirInfo.path, 'team.md'), { dryRun: true });
if (backfill.repaired) {
info('Would backfill .squad/casting/registry.json from .squad/team.md');
} else {
warnCastingRepairSkipped('Would not backfill .squad/casting/registry.json from .squad/team.md', backfill);
}
const templatesDir = getTemplatesDir();
const agentSrc = path.join(templatesDir, 'squad.agent.md.template');
if (storage.existsSync(agentSrc)) {
Expand Down
Loading
Loading