Repository navigation
feat(sdk,cli): external state storage — .squad/ outside working tree (#792) #797
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
19a851b
feat(sdk,cli): external state storage — move .squad/ out of working t…
invalid-email-address 797c31d
fix: address all 10 Copilot review comments on external state (#797)
invalid-email-address d1b80d1
fix: wire externalize/internalize commands into CLI entry router
invalid-email-address fc62bf6
Merge branch 'dev' into squad/792-external-state
tamirdresher ef5af9e
fix: address Copilot review comments
invalid-email-address 6c76f6f
Merge remote-tracking branch 'origin/dev' into squad/792-external-state
invalid-email-address acc2611
fix: address Copilot review — sanitize projectKey, wire CLI commands,…
invalid-email-address dc44f5d
Merge branch 'dev' into squad/792-external-state
tamirdresher 78836e6
Merge branch 'dev' into squad/792-external-state
tamirdresher 13fd033
fix: normalize path separators in deriveProjectKey before basename
invalid-email-address e6dbd37
Merge branch 'squad/792-external-state' of https://github.com/bradyga…
invalid-email-address d154ee8
Merge branch 'dev' into squad/792-external-state
tamirdresher File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| */ | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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'/internalizehandling), so users won't be able to invoke it. Please add routing inpackages/squad-cli/src/cli-entry.ts(and updatehelpoutput if applicable) so the feature is reachable.