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

Add external state storage — move .squad/ out of the working tree (#792)

- New `stateLocation: 'external'` option in `.squad/config.json`
- `resolveExternalStateDir()` resolves state under the platform-specific global Squad directory (`resolveGlobalSquadPath()`)
- `deriveProjectKey()` generates a stable key from the repo path (cross-platform)
- `resolveSquadPaths()` honors external state location
- `squad externalize` moves state out, `squad internalize` moves it back
- State survives branch switches, invisible to `git status`, never pollutes PRs
- Thin `.squad/config.json` marker stays in repo (gitignored)
- Path traversal protection on projectKey
- 12 new tests
14 changes: 14 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,20 @@ async function main(): Promise<void> {
return;
}

if (cmd === 'externalize') {
const { runExternalize } = await import('./cli/commands/externalize.js');
const rawKey = args.includes('--key') ? args[args.indexOf('--key') + 1] : undefined;
const projectKey = rawKey ? rawKey.replace(/[\/\\\.]/g, '_') : undefined;
runExternalize(process.cwd(), projectKey);
return;
}

if (cmd === 'internalize') {
const { runInternalize } = await import('./cli/commands/externalize.js');
runInternalize(process.cwd());
return;
}

if (cmd === 'rc' || cmd === 'remote-control') {
const { runRC } = await import('./cli/commands/rc.js');
const hasTunnel = args.includes('--tunnel');
Expand Down
230 changes: 230 additions & 0 deletions packages/squad-cli/src/cli/commands/externalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/**
* squad externalize — move .squad/ state out of the working tree.
*
* Moves all squad state to `{resolveGlobalSquadPath()}/projects/{projectKey}/`
* (platform-specific: `%APPDATA%/squad/` on Windows, `~/Library/Application Support/squad/`
* on macOS, `$XDG_CONFIG_HOME/squad/` on Linux) and writes a thin `.squad/config.json`
* marker in the repo. After externalization:
* - State survives branch switches (not tied to the working tree)
* - State is invisible to `git status` and never pollutes PRs
* - A `.squad/config.json` marker lets the walk-up resolver find state
*
* To restore local state, run `squad internalize`.
*
* @module cli/commands/externalize
*/
Comment on lines +1 to +15

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command module is added, but it doesn't appear to be wired into the CLI entry router (no cmd === 'externalize' / internalize handling), so users won't be able to invoke it. Please add routing in packages/squad-cli/src/cli-entry.ts (and update help output if applicable) so the feature is reachable.

Copilot uses AI. Check for mistakes.

import path from 'node:path';
import { FSStorageProvider, resolveExternalStateDir, deriveProjectKey } from '@bradygaster/squad-sdk';
import { fatal } from '../core/errors.js';

const storage = new FSStorageProvider();

/** Directories under .squad/ that hold the actual state */
const STATE_DIRS = [
'agents', 'casting', 'decisions', 'identity', 'orchestration-log',
'log', 'plugins', 'templates', 'skills', 'sessions', '.scratch',
];

/** Files under .squad/ that hold state (not config.json — that stays) */
const STATE_FILES = [
'team.md', 'routing.md', 'ceremonies.md', 'decisions.md',
'decisions-archive.md', '.first-run',
];

/**
* Move .squad/ state to the external directory.
*
* @param projectDir - Absolute path to the project root (where .squad/ lives).
* @param projectKey - Optional explicit project key. Defaults to repo basename slug.
*/
export function runExternalize(projectDir: string, projectKey?: string): void {
const squadDir = path.join(projectDir, '.squad');
if (!storage.existsSync(squadDir)) {
fatal('.squad/ directory not found. Run `squad init` first.');
}

// Derive project key
const key = projectKey || deriveProjectKey(projectDir);
const externalDir = resolveExternalStateDir(key, true);

console.log(`📦 Externalizing .squad/ state to: ${externalDir}`);

let movedCount = 0;

// Move directories
for (const dir of STATE_DIRS) {
const src = path.join(squadDir, dir);
if (!storage.existsSync(src)) continue;
const dest = path.join(externalDir, dir);
// Copy contents into the external state directory. If destination files
// already exist, they may be overwritten by copyDirRecursive().
copyDirRecursive(src, dest);
storage.deleteDirSync?.(src);
movedCount++;
}

// Move files
for (const file of STATE_FILES) {
const src = path.join(squadDir, file);
if (!storage.existsSync(src)) continue;
const dest = path.join(externalDir, file);
const content = storage.readSync(src);
if (content != null) {
storage.mkdirSync(path.dirname(dest), { recursive: true });
storage.writeSync(dest, content);
}
storage.deleteSync?.(src);
movedCount++;
}

// Write thin config.json marker, preserving any existing config fields
const configPath = path.join(squadDir, 'config.json');
let existingConfig: Record<string, unknown> = {};
if (storage.existsSync(configPath)) {
try {
const raw = storage.readSync(configPath);
if (raw != null) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
existingConfig = parsed as Record<string, unknown>;
}
}
} catch { /* start fresh if config is malformed */ }
}

const config = {
...existingConfig,
version: 1,
teamRoot: '.',
projectKey: key,
stateLocation: 'external',
};
storage.writeSync(
configPath,
JSON.stringify(config, null, 2) + '\n',
);

// Ensure config.json is gitignored (it's machine-local)
ensureGitignored(projectDir, '.squad/config.json');

console.log(`✅ Externalized ${movedCount} items. State now at: ${externalDir}`);
console.log(` .squad/config.json marker left in repo (gitignored).`);
console.log(` Run \`squad internalize\` to move state back.`);
}

/**
* Move externalized state back into the working tree.
*/
export function runInternalize(projectDir: string): void {
const squadDir = path.join(projectDir, '.squad');
const configPath = path.join(squadDir, 'config.json');

if (!storage.existsSync(configPath)) {
fatal('.squad/config.json not found. State is already local or not initialized.');
}

let config: { stateLocation?: string; projectKey?: string };
try {
config = JSON.parse(storage.readSync(configPath) ?? '{}');
} catch {
fatal('.squad/config.json is malformed.');
return;
}

if (config.stateLocation !== 'external') {
fatal('State is already local (stateLocation is not "external").');
}

const key = config.projectKey || deriveProjectKey(projectDir);
const externalDir = resolveExternalStateDir(key, false);

if (!storage.existsSync(externalDir)) {
fatal(`External state directory not found: ${externalDir}`);
}

console.log(`📥 Internalizing state from: ${externalDir}`);

let movedCount = 0;

// Copy directories back
for (const dir of STATE_DIRS) {
const src = path.join(externalDir, dir);
if (!storage.existsSync(src)) continue;
const dest = path.join(squadDir, dir);
copyDirRecursive(src, dest);
movedCount++;
}

// Copy files back
for (const file of STATE_FILES) {
const src = path.join(externalDir, file);
if (!storage.existsSync(src)) continue;
const content = storage.readSync(src);
if (content != null) {
storage.writeSync(path.join(squadDir, file), content);
}
movedCount++;
}

// Remove external-state fields from config.json (stateLocation, teamRoot,
// projectKey) to restore true local mode. Preserve any other settings
// (e.g. consult, stateBackend). If nothing meaningful remains, delete the file
// so loadDirConfig() returns null and resolveSquadPaths() falls through to
// local mode.
let fullConfig: Record<string, unknown> = {};
try {
const raw = storage.readSync(configPath);
if (raw != null) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
fullConfig = parsed as Record<string, unknown>;
}
}
} catch { /* treat malformed as empty */ }

delete fullConfig.stateLocation;
delete fullConfig.teamRoot;
delete fullConfig.projectKey;

// Check if any meaningful fields remain beyond version
const { version: _v, ...rest } = fullConfig;
if (Object.keys(rest).length > 0) {
storage.writeSync(configPath, JSON.stringify(fullConfig, null, 2) + '\n');
} else {
storage.deleteSync?.(configPath);
}

console.log(`✅ Internalized ${movedCount} items. State is back in .squad/.`);
}

function copyDirRecursive(src: string, dest: string): void {
storage.mkdirSync(dest, { recursive: true });
const entries = storage.listSync?.(src) ?? [];
for (const entry of entries) {
const srcPath = path.join(src, entry);
const destPath = path.join(dest, entry);
if (storage.isDirectorySync(srcPath)) {
copyDirRecursive(srcPath, destPath);
} else {
const content = storage.readSync(srcPath);
if (content != null) {
storage.writeSync(destPath, content);
}
}
}
}

function ensureGitignored(projectDir: string, entry: string): void {
const gitignorePath = path.join(projectDir, '.gitignore');
let existing = '';
if (storage.existsSync(gitignorePath)) {
existing = storage.readSync(gitignorePath) ?? '';
}
if (!existing.includes(entry)) {
const block = (existing && !existing.endsWith('\n') ? '\n' : '')
+ '# Squad: local config (machine-specific, never commit)\n'
+ entry + '\n';
storage.appendSync(gitignorePath, block);
}
}
2 changes: 1 addition & 1 deletion packages/squad-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const pkg = require('../package.json');
export const VERSION: string = pkg.version;

// Export public API
export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile } from './resolution.js';
export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensurePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode, scratchDir, scratchFile, deriveProjectKey, resolveExternalStateDir } from './resolution.js';
export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js';
export * from './config/index.js';
export * from './agents/onboarding.js';
Expand Down
71 changes: 71 additions & 0 deletions packages/squad-sdk/src/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface SquadDirConfig {
consult?: boolean;
/** True when extraction is disabled for consult sessions (read-only consultation) */
extractionDisabled?: boolean;
/** Where state is stored: 'external' when moved out of the working tree */
stateLocation?: string;
/** State storage backend: worktree | external | git-notes | orphan */
stateBackend?: string;
}
Expand Down Expand Up @@ -225,6 +227,7 @@ export function loadDirConfig(squadDir: string): SquadDirConfig | null {
projectKey: typeof parsed.projectKey === 'string' ? parsed.projectKey : null,
consult: parsed.consult === true ? true : undefined,
extractionDisabled: parsed.extractionDisabled === true ? true : undefined,
stateLocation: typeof parsed.stateLocation === 'string' ? parsed.stateLocation : undefined,
stateBackend: typeof parsed.stateBackend === 'string' ? parsed.stateBackend : undefined,
};
}
Expand Down Expand Up @@ -528,3 +531,71 @@ export function scratchFile(squadRoot: string, prefix: string, ext: string = '.t
}
return filePath;
}

// ============================================================================
// External state storage (Issue #792)
// ============================================================================

/**
* Derive a stable project key from a project directory path.
*
* Takes the basename of the path, lowercases it, and replaces unsafe characters
* with dashes. Returns `'unknown-project'` if the basename is empty (e.g.,
* filesystem root).
*
* @param projectDir - Absolute path to the project root.
* @returns A sanitized, lowercase project key suitable for use as a directory name.
*/
export function deriveProjectKey(projectDir: string): string {
const normalized = projectDir.replace(/\\/g, '/');
const base = path.basename(normalized);
if (!base) return 'unknown-project';

const sanitized = base
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/^-+|-+$/g, '');

return sanitized || 'unknown-project';
}

/**
* Resolve the external state directory for a project.
*
* Returns `{globalDir}/projects/{sanitizedKey}/` where `globalDir` is the
* platform-specific global config directory (e.g., `%APPDATA%/squad` on Windows,
* `~/Library/Application Support/squad` on macOS, `$XDG_CONFIG_HOME/squad` or
* `~/.config/squad` on Linux).
*
* Validates the project key to prevent path traversal. Throws if the key
* is empty or contains `..` sequences.
*
* @param projectKey - The project key (from deriveProjectKey or user-supplied).
* @param create - Whether to create the directory if it doesn't exist (default: true).
* @returns Absolute path to the project's external state directory.
* @throws If projectKey is empty or contains path traversal sequences.
*/
export function resolveExternalStateDir(projectKey: string, create: boolean = true): string {
if (!projectKey || projectKey.includes('..')) {
throw new Error('Invalid project key');
}

// Sanitize: replace path separators and unsafe chars with dashes
const sanitized = projectKey
.replace(/[/\\]/g, '-')
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/^-+|-+$/g, '');

if (!sanitized) {
throw new Error('Invalid project key');
}

const globalDir = resolveGlobalSquadPath();
const projectsDir = path.join(globalDir, 'projects', sanitized);

if (create && !storage.existsSync(projectsDir)) {
storage.mkdirSync(projectsDir, { recursive: true });
}

return projectsDir;
}
Loading
Loading