diff --git a/.changeset/fix-export-import-blank-files.md b/.changeset/fix-export-import-blank-files.md new file mode 100644 index 000000000..4a27cfeef --- /dev/null +++ b/.changeset/fix-export-import-blank-files.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Fix export/import producing blank decisions.md and team.md files. Export now includes these files in the manifest, and import restores their content. diff --git a/index.cjs b/index.cjs index fbdbafcb3..f3c90c9f2 100644 --- a/index.cjs +++ b/index.cjs @@ -1027,6 +1027,15 @@ if (cmd === 'export') { skills: [] }; + // Read top-level squad files (decisions.md, team.md) + const decisionsMd = path.join(dest, '.ai-team', 'decisions.md'); + if (fs.existsSync(decisionsMd)) { + manifest.decisions = fs.readFileSync(decisionsMd, 'utf8'); + } + if (fs.existsSync(teamMd)) { + manifest.team = fs.readFileSync(teamMd, 'utf8'); + } + // Read casting state const castingDir = path.join(dest, '.ai-team', 'casting'); for (const file of ['registry.json', 'policy.json', 'history.json']) { @@ -1090,7 +1099,7 @@ if (cmd === 'export') { const displayPath = path.relative(dest, outPath) || path.basename(outPath); console.log(`${GREEN}✓${RESET} Exported squad to ${displayPath}`); - console.log(`${DIM}⚠ Review agent histories before sharing — they may contain project-specific information${RESET}`); + console.log(`${DIM}⚠ Review agent histories, decisions, and team content before sharing — they may contain project-specific information${RESET}`); process.exit(0); } @@ -1147,9 +1156,17 @@ if (cmd === 'import') { fs.mkdirSync(path.join(aiTeamDir, 'log'), { recursive: true }); fs.mkdirSync(path.join(aiTeamDir, 'skills'), { recursive: true }); - // Write empty project-specific files - fs.writeFileSync(path.join(aiTeamDir, 'decisions.md'), ''); - fs.writeFileSync(path.join(aiTeamDir, 'team.md'), ''); + // Validate optional string fields + if (manifest.decisions !== undefined && typeof manifest.decisions !== 'string') { + fatal('Invalid export file: "decisions" field must be a string'); + } + if (manifest.team !== undefined && typeof manifest.team !== 'string') { + fatal('Invalid export file: "team" field must be a string'); + } + + // Write project-specific files from manifest (fall back to empty if not present) + fs.writeFileSync(path.join(aiTeamDir, 'decisions.md'), manifest.decisions || ''); + fs.writeFileSync(path.join(aiTeamDir, 'team.md'), manifest.team || ''); // Write casting state for (const [key, value] of Object.entries(manifest.casting)) { @@ -1189,7 +1206,6 @@ if (cmd === 'import') { : `skill-${index}`; return { skillContent, skillName }; }); - const hasForce = typeof force !== 'undefined' && force; if (fs.existsSync(copilotSkillsImportDir)) { if (hasForce) { diff --git a/packages/squad-cli/src/cli/commands/export.ts b/packages/squad-cli/src/cli/commands/export.ts index 6e8e17f6e..3d02265f1 100644 --- a/packages/squad-cli/src/cli/commands/export.ts +++ b/packages/squad-cli/src/cli/commands/export.ts @@ -9,6 +9,7 @@ import type { RepoSpec } from '@bradygaster/squad-sdk'; import { detectSquadDir } from '../core/detect-squad-dir.js'; import { success, warn, info } from '../core/output.js'; import { fatal } from '../core/errors.js'; +import { getPackageVersion } from '../core/version.js'; import { ghAvailable, ghAuthenticated } from '../core/gh-cli.js'; interface ExportManifest { @@ -21,6 +22,8 @@ interface ExportManifest { casting: Record; agents: Record; skills: string[]; + decisions?: string; + team?: string; } export interface ExportRepoOptions { @@ -35,20 +38,26 @@ function buildManifest(dest: string, storage: FSStorageProvider, squadInfo: { pa const manifest: ExportManifest = { version: '1.0', exported_at: new Date().toISOString(), - squad_version: '0.6.0', + squad_version: getPackageVersion(), casting: {}, agents: {}, - skills: [] + skills: [], }; // Read top-level squad files (team.md, decisions.md, routing.md) const teamMdPath = path.join(squadInfo.path, 'team.md'); const teamMdContent = storage.readSync(teamMdPath); - if (teamMdContent !== undefined) manifest.team_md = teamMdContent; + if (teamMdContent !== undefined) { + manifest.team_md = teamMdContent; + manifest.team = teamMdContent; + } const decisionsMdPath = path.join(squadInfo.path, 'decisions.md'); const decisionsMdContent = storage.readSync(decisionsMdPath); - if (decisionsMdContent !== undefined) manifest.decisions_md = decisionsMdContent; + if (decisionsMdContent !== undefined) { + manifest.decisions_md = decisionsMdContent; + manifest.decisions = decisionsMdContent; + } const routingMdPath = path.join(squadInfo.path, 'routing.md'); const routingMdContent = storage.readSync(routingMdPath); @@ -155,7 +164,7 @@ export async function runExport(dest: string, outPath?: string, repoOptions?: Ex fatal(`Failed to export to repo: ${(err as Error).message}`); } - warn('Review agent histories before sharing — they may contain project-specific information'); + warn('Review agent histories, decisions, and team content before sharing — they may contain project-specific information'); return; } @@ -170,5 +179,5 @@ export async function runExport(dest: string, outPath?: string, repoOptions?: Ex const displayPath = path.relative(dest, finalOutPath) || path.basename(finalOutPath); success(`Exported squad to ${displayPath}`); - warn('Review agent histories before sharing — they may contain project-specific information'); + warn('Review agent histories, decisions, and team content before sharing — they may contain project-specific information'); } diff --git a/packages/squad-cli/src/cli/commands/import.ts b/packages/squad-cli/src/cli/commands/import.ts index 183d6ee62..29fc1cef3 100644 --- a/packages/squad-cli/src/cli/commands/import.ts +++ b/packages/squad-cli/src/cli/commands/import.ts @@ -22,6 +22,8 @@ interface ImportManifest { casting: Record; agents: Record; skills: string[]; + decisions?: string; + team?: string; } export interface ImportRepoOptions { @@ -79,9 +81,28 @@ function applyManifest( storage.mkdirSync(path.join(squadDir, 'log'), { recursive: true }); storage.mkdirSync(path.join(dest, '.copilot', 'skills'), { recursive: true }); - // Write empty project-specific files - storage.writeSync(path.join(squadDir, 'decisions.md'), manifest.decisions_md ?? ''); - storage.writeSync(path.join(squadDir, 'team.md'), manifest.team_md ?? ''); + if (manifest.decisions_md !== undefined && typeof manifest.decisions_md !== 'string') { + fatal('Invalid export file: "decisions_md" field must be a string'); + } + if (manifest.team_md !== undefined && typeof manifest.team_md !== 'string') { + fatal('Invalid export file: "team_md" field must be a string'); + } + if (manifest.routing_md !== undefined && typeof manifest.routing_md !== 'string') { + fatal('Invalid export file: "routing_md" field must be a string'); + } + if (manifest.decisions !== undefined && typeof manifest.decisions !== 'string') { + fatal('Invalid export file: "decisions" field must be a string'); + } + if (manifest.team !== undefined && typeof manifest.team !== 'string') { + fatal('Invalid export file: "team" field must be a string'); + } + + const decisionsContent = manifest.decisions_md ?? manifest.decisions ?? ''; + const teamContent = manifest.team_md ?? manifest.team ?? ''; + + // Write top-level squad files + storage.writeSync(path.join(squadDir, 'decisions.md'), decisionsContent); + storage.writeSync(path.join(squadDir, 'team.md'), teamContent); if (manifest.routing_md !== undefined) { storage.writeSync(path.join(squadDir, 'routing.md'), manifest.routing_md); } diff --git a/test/skills-export-import.test.cjs b/test/skills-export-import.test.cjs index 8e4e673a2..f9a1f5e68 100644 --- a/test/skills-export-import.test.cjs +++ b/test/skills-export-import.test.cjs @@ -262,3 +262,88 @@ name: report-test assert.ok(importResult.stdout.includes('skill'), 'import output should mention skills'); }); }); + +describe('decisions.md and team.md survive export/import round-trip (#1122)', () => { + let tmpDir1; + let tmpDir2; + + beforeEach(() => { + tmpDir1 = makeTempDir(); + tmpDir2 = makeTempDir(); + }); + + afterEach(() => { + cleanDir(tmpDir1); + cleanDir(tmpDir2); + }); + + it('exports decisions.md and team.md content in manifest', () => { + initSquad(tmpDir1); + + // Write content to decisions.md and team.md + const aiTeamDir = path.join(tmpDir1, '.ai-team'); + fs.writeFileSync(path.join(aiTeamDir, 'decisions.md'), '# Decisions\n\n## Use TypeScript\nWe chose TypeScript for type safety.\n'); + fs.writeFileSync(path.join(aiTeamDir, 'team.md'), '# Team Roster\n\n## Lead Architect\nDesigns systems.\n'); + + // Export + const exportPath = path.join(tmpDir1, 'squad-export.json'); + const exportResult = runSquad(['export'], tmpDir1); + assert.equal(exportResult.exitCode, 0, `export should succeed: ${exportResult.stdout}`); + + // Verify manifest contains decisions and team fields + const manifest = JSON.parse(fs.readFileSync(exportPath, 'utf8')); + assert.equal(manifest.decisions, '# Decisions\n\n## Use TypeScript\nWe chose TypeScript for type safety.\n', 'manifest should contain decisions.md content'); + assert.equal(manifest.team, '# Team Roster\n\n## Lead Architect\nDesigns systems.\n', 'manifest should contain team.md content'); + }); + + it('imports decisions.md and team.md content from manifest', () => { + initSquad(tmpDir1); + + // Write content to decisions.md and team.md + const aiTeamDir = path.join(tmpDir1, '.ai-team'); + fs.writeFileSync(path.join(aiTeamDir, 'decisions.md'), '# Decisions\n\nImportant decision here.\n'); + fs.writeFileSync(path.join(aiTeamDir, 'team.md'), '# Team\n\nTeam member info here.\n'); + + // Export from dir1 + const exportPath = path.join(tmpDir1, 'squad-export.json'); + runSquad(['export'], tmpDir1); + + // Init and import into dir2 + initSquad(tmpDir2); + const importResult = runSquad(['import', exportPath, '--force'], tmpDir2); + assert.equal(importResult.exitCode, 0, `import should succeed: ${importResult.stdout}`); + + // Verify imported files have the correct content + const importedDecisions = fs.readFileSync(path.join(tmpDir2, '.ai-team', 'decisions.md'), 'utf8'); + const importedTeam = fs.readFileSync(path.join(tmpDir2, '.ai-team', 'team.md'), 'utf8'); + assert.equal(importedDecisions, '# Decisions\n\nImportant decision here.\n', 'decisions.md should be preserved'); + assert.equal(importedTeam, '# Team\n\nTeam member info here.\n', 'team.md should be preserved'); + }); + + it('imports older manifests without decisions/team fields successfully', () => { + initSquad(tmpDir1); + + // Create a manifest without decisions and team fields (older format) + const oldManifest = { + version: '1.0', + exported_at: new Date().toISOString(), + squad_version: '0.6.0', + casting: { registry: { agents: [] } }, + agents: {}, + skills: [] + }; + const exportPath = path.join(tmpDir1, 'old-manifest.json'); + fs.writeFileSync(exportPath, JSON.stringify(oldManifest, null, 2)); + + // Init and import into dir2 + initSquad(tmpDir2); + const importResult = runSquad(['import', exportPath, '--force'], tmpDir2); + assert.equal(importResult.exitCode, 0, `import of old manifest should succeed: ${importResult.stdout}`); + + // Verify files exist (empty is fine for old manifests) + const importedDecisions = fs.readFileSync(path.join(tmpDir2, '.ai-team', 'decisions.md'), 'utf8'); + const importedTeam = fs.readFileSync(path.join(tmpDir2, '.ai-team', 'team.md'), 'utf8'); + assert.equal(importedDecisions, '', 'decisions.md should be empty for old manifest'); + assert.equal(importedTeam, '', 'team.md should be empty for old manifest'); + }); +});