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
12 changes: 12 additions & 0 deletions .changeset/cli-upgrade-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Fix CLI and upgrade bugs:
- `squad externalize` and `squad internalize` now appear in `squad --help` output (#1050)
- `squad build` writes files to the externalized state directory when applicable (#1048)
- `squad new agent` correctly adds agent definitions to `squad.config.ts` (#1047)
- `squad upgrade` backs up customized `squad.agent.md` before overwriting; supports `--dry-run` (#1052)
- Explicit `@agent` mentions bypass direct-response handler and route to named agent (#1029)
- `state-mcp` server uses lazy initialization to avoid blocking multi-MCP startup (#1353)
6 changes: 6 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ async function main(): Promise<void> {
console.log(` upstream sync [name]`);
console.log(` ${BOLD}economy${RESET} Toggle economy mode (cost-conscious model selection)`);
console.log(` Usage: economy [on|off]`);
console.log(` ${BOLD}externalize${RESET} Move .squad/ state out of the working tree`);
console.log(` Stores state in platform-local storage`);
console.log(` Flags: --key <name> (explicit project key)`);
console.log(` ${BOLD}internalize${RESET} Restore externalized state into the working tree`);

console.log(` ${BOLD}version${RESET} Print installed version`);
console.log(` ${BOLD}help${RESET} Show this help message`);
Expand Down Expand Up @@ -401,6 +405,7 @@ async function main(): Promise<void> {
const selfUpgrade = args.includes('--self');
const forceUpgrade = args.includes('--force');
const insider = args.includes('--insider');
const dryRun = args.includes('--dry-run');
const dest = hasGlobal ? (await lazySquadSdk()).resolveGlobalSquadPath() : getSquadStartDir();

// Parse --state-backend for backend migration
Expand Down Expand Up @@ -464,6 +469,7 @@ async function main(): Promise<void> {
migrateDirectory: migrateDir,
self: selfUpgrade,
force: forceUpgrade,
dryRun,
});
}

Expand Down
35 changes: 29 additions & 6 deletions packages/squad-cli/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url';
import { FSStorageProvider } from '@bradygaster/squad-sdk';
import { success, warn, info, dim, BOLD, RESET, YELLOW, GREEN, RED } from '../core/output.js';
import { fatal } from '../core/errors.js';
import { effectiveSquadDir } from '../core/effective-squad-dir.js';

import type {
SquadSDKConfig,
Expand Down Expand Up @@ -430,7 +431,7 @@ interface BuildResult {
drifted: string[];
}

function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult {
function writeFiles(cwd: string, files: GeneratedFile[], stateDir?: string): BuildResult {
const storage = new FSStorageProvider();
let written = 0;
let skipped = 0;
Expand All @@ -442,7 +443,14 @@ function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult {
continue;
}

const absPath = path.join(cwd, file.relPath);
// When state is externalized, .squad/ paths are written to the external dir
const baseDir = (stateDir && file.relPath.startsWith('.squad/'))
? path.join(stateDir, file.relPath.slice('.squad/'.length))
: path.join(cwd, file.relPath);

const absPath = (stateDir && file.relPath.startsWith('.squad/'))
? baseDir
: path.join(cwd, file.relPath);

storage.writeSync(absPath, file.content);
written++;
Expand All @@ -451,15 +459,18 @@ function writeFiles(cwd: string, files: GeneratedFile[]): BuildResult {
return { files, written, skipped, drifted };
}

function checkDrift(cwd: string, files: GeneratedFile[]): { drifted: string[]; clean: string[] } {
function checkDrift(cwd: string, files: GeneratedFile[], stateDir?: string): { drifted: string[]; clean: string[] } {
const storage = new FSStorageProvider();
const drifted: string[] = [];
const clean: string[] = [];

for (const file of files) {
if (isProtected(file.relPath)) continue;

const absPath = path.join(cwd, file.relPath);
const absPath = (stateDir && file.relPath.startsWith('.squad/'))
? path.join(stateDir, file.relPath.slice('.squad/'.length))
: path.join(cwd, file.relPath);

const existing = storage.readSync(absPath);
if (existing === undefined) {
drifted.push(file.relPath);
Expand Down Expand Up @@ -500,6 +511,18 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise
return;
}

// Resolve effective state directory (respects externalized state)
let stateDir: string | undefined;
try {
const dirs = effectiveSquadDir(cwd);
if (dirs.stateDir !== dirs.local.path) {
stateDir = dirs.stateDir;
dim(` State dir: ${stateDir} (externalized)`);
}
} catch {
// No .squad/ found — build will proceed with default cwd-relative writes
}

// Load config
const { config, source } = await loadSquadConfig(cwd);
dim(` Config: ${path.relative(cwd, source) || source}`);
Expand All @@ -514,7 +537,7 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise

// --check mode
if (options.check) {
const { drifted, clean } = checkDrift(cwd, files);
const { drifted, clean } = checkDrift(cwd, files, stateDir);
if (drifted.length === 0) {
success(`All ${clean.length} generated files match disk — no drift detected.`);
return;
Expand Down Expand Up @@ -542,7 +565,7 @@ export async function runBuild(cwd: string, options: BuildOptions = {}): Promise
}

// Default: write files
const result = writeFiles(cwd, files);
const result = writeFiles(cwd, files, stateDir);

success(`squad build complete — generated ${result.written} file(s)`);
if (result.skipped > 0) {
Expand Down
66 changes: 47 additions & 19 deletions packages/squad-cli/src/cli/commands/state-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,29 +160,57 @@ export function createStateMcpSession(
}

export async function runStateMcp(startDir = process.cwd()): Promise<void> {
const registry = createStateMcpToolRegistry(startDir);
const runtimeTools = new Map(registry.getTools().map(tool => [tool.name, tool]));
const tools = Object.entries(MCP_TOOL_ALIASES).map(([mcpName, runtimeName]) => {
const tool = runtimeTools.get(runtimeName);
if (!tool) throw new Error(`Missing Squad runtime state tool: ${runtimeName}`);
return { mcpName, runtimeName, tool };
});
const toolMap = new Map(tools.map(entry => [entry.mcpName, entry]));
// Lazy initialization: resolve state on first tool call, not at startup (#1353)
// This ensures the MCP server connects quickly and doesn't block other MCPs
let registry: ToolRegistry | undefined;
let initError: Error | undefined;

function getRegistry(): ToolRegistry {
if (initError) throw initError;
if (!registry) {
try {
registry = createStateMcpToolRegistry(startDir);
} catch (err) {
initError = err instanceof Error ? err : new Error(String(err));
throw initError;
}
}
return registry;
}

const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });

server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: tools.map(tool => ({
name: tool.mcpName,
description: tool.tool.description,
inputSchema: tool.tool.parameters as {
type: 'object';
properties?: Record<string, object>;
required?: string[];
},
})),
}));
server.setRequestHandler(ListToolsRequestSchema, () => {
const reg = getRegistry();
const runtimeTools = new Map(reg.getTools().map(tool => [tool.name, tool]));
const tools = Object.entries(MCP_TOOL_ALIASES).map(([mcpName, runtimeName]) => {
const tool = runtimeTools.get(runtimeName);
if (!tool) throw new Error(`Missing Squad runtime state tool: ${runtimeName}`);
return { mcpName, runtimeName, tool };
});
return {
tools: tools.map(tool => ({
name: tool.mcpName,
description: tool.tool.description,
inputSchema: tool.tool.parameters as {
type: 'object';
properties?: Record<string, object>;
required?: string[];
},
})),
};
});

server.setRequestHandler(CallToolRequestSchema, async request => {
const reg = getRegistry();
const runtimeTools = new Map(reg.getTools().map(tool => [tool.name, tool]));
const tools = Object.entries(MCP_TOOL_ALIASES).map(([mcpName, runtimeName]) => {
const tool = runtimeTools.get(runtimeName);
if (!tool) throw new Error(`Missing Squad runtime state tool: ${runtimeName}`);
return { mcpName, runtimeName, tool };
});
const toolMap = new Map(tools.map(entry => [entry.mcpName, entry]));

const entry = toolMap.get(request.params.name);
if (!entry) {
throw new Error(`Unknown Squad state tool: ${request.params.name}`);
Expand Down
3 changes: 3 additions & 0 deletions packages/squad-cli/src/cli/core/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ const COMMAND_HELP: Record<string, HelpPrinter> = {
console.log(`Usage: squad upgrade [options]\n`);
console.log(`Overwrites Squad-owned files (squad.agent.md, .squad/templates/) while`);
console.log(`leaving your team state under .squad/ and .ai-team/ untouched.\n`);
console.log(`Local customizations to squad.agent.md are backed up automatically.`);
console.log(`Use --dry-run to preview changes before applying.\n`);
console.log(`Options:`);
console.log(` ${BOLD}--dry-run${RESET} Preview changes without writing`);
console.log(` ${BOLD}--global${RESET} Upgrade the personal (global) squad`);
console.log(` ${BOLD}--migrate-directory${RESET} Rename legacy .ai-team/ to .squad/`);
console.log(` ${BOLD}--state-backend <type>${RESET} Migrate to a new backend (orphan|two-layer)`);
Expand Down
52 changes: 51 additions & 1 deletion packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export interface UpgradeOptions {
force?: boolean;
/** When --self, install the insider (prerelease) tag instead of latest. */
insider?: boolean;
/** Preview what upgrade would change without writing. */
dryRun?: boolean;
}

export interface UpdateInfo {
Expand Down Expand Up @@ -235,11 +237,38 @@ function detectIsGitHubForMcp(dest: string, config: Record<string, unknown>): bo
return true;
}

function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: string, mcpConfigMode: McpConfigMode, isGitHub: boolean): void {
function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: string, mcpConfigMode: McpConfigMode, isGitHub: boolean, options?: { dryRun?: boolean }): void {
let agentContent = storage.readSync(agentSrc) ?? '';
if (mcpConfigMode === 'agent-frontmatter') {
agentContent = injectMcpFrontmatter(agentContent, isGitHub, cliVersion);
}

// Back up locally-customized squad.agent.md before overwriting (#1052)
if (storage.existsSync(agentDest)) {
const existing = (storage.readSync(agentDest) ?? '').replace(/\r\n/g, '\n');
// Strip the version stamp before comparing (version line changes every upgrade)
const stripVersion = (s: string) => s.replace(/<!-- squad-cli v[\d.]+[-\w.]* -->\n?/g, '');
const normalizedExisting = stripVersion(existing);
const normalizedTemplate = stripVersion(agentContent.replace(/\r\n/g, '\n'));

if (normalizedExisting !== normalizedTemplate && normalizedExisting.trim().length > 0) {
const backupPath = agentDest + '.local-backup';
if (options?.dryRun) {
warn(`squad.agent.md has local customizations — would back up to ${path.basename(backupPath)}`);
return;
}
storage.writeSync(backupPath, existing);
warn(`squad.agent.md has local customizations — backed up to ${path.basename(backupPath)}`);
info(` To restore: copy ${path.basename(backupPath)} → ${path.basename(agentDest)}`);
} else if (options?.dryRun) {
info('squad.agent.md is unmodified — would refresh with latest template');
return;
}
} else if (options?.dryRun) {
info('squad.agent.md does not exist — would create from template');
return;
}

storage.writeSync(agentDest, agentContent);
stampVersion(agentDest, cliVersion);
}
Expand Down Expand Up @@ -976,6 +1005,27 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr

const projectType = detectProjectType(dest);

// --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 templatesDir = getTemplatesDir();
const agentSrc = path.join(templatesDir, 'squad.agent.md.template');
if (storage.existsSync(agentSrc)) {
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp, { dryRun: true });
}
const filesToUpgrade = TEMPLATE_MANIFEST.filter(f => f.overwriteOnUpgrade && f.source !== 'squad.agent.md.template');
if (filesToUpgrade.length > 0) {
info(`Would overwrite ${filesToUpgrade.length} squad-owned file(s):`);
for (const file of filesToUpgrade) {
const destPath = path.join(squadDirInfo.path, file.destination);
const exists = storage.existsSync(destPath);
console.log(` ${exists ? 'overwrite' : 'create'} ${file.destination}`);
}
}
console.log(`\nRun without --dry-run to apply.\n`);
return { fromVersion: oldVersion, toVersion: cliVersion, filesUpdated: [], migrationsRun: [] };
}

if (isAlreadyCurrent) {
info(`Already up to date (v${cliVersion})`);

Expand Down
Loading