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
5 changes: 5 additions & 0 deletions .changeset/fix-export-import-blank-files.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 21 additions & 5 deletions index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Comment thread
bradygaster marked this conversation as resolved.
Resolved

// Read casting state
const castingDir = path.join(dest, '.ai-team', 'casting');
for (const file of ['registry.json', 'policy.json', 'history.json']) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1189,7 +1206,6 @@ if (cmd === 'import') {
: `skill-${index}`;
return { skillContent, skillName };
});
const hasForce = typeof force !== 'undefined' && force;

if (fs.existsSync(copilotSkillsImportDir)) {
if (hasForce) {
Expand Down
21 changes: 15 additions & 6 deletions packages/squad-cli/src/cli/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +22,8 @@ interface ExportManifest {
casting: Record<string, unknown>;
agents: Record<string, { charter?: string; history?: string }>;
skills: string[];
decisions?: string;
team?: string;
}

export interface ExportRepoOptions {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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');
}
27 changes: 24 additions & 3 deletions packages/squad-cli/src/cli/commands/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface ImportManifest {
casting: Record<string, unknown>;
agents: Record<string, { charter?: string; history?: string }>;
skills: string[];
decisions?: string;
team?: string;
}

export interface ImportRepoOptions {
Expand Down Expand Up @@ -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);
}
Expand Down
85 changes: 85 additions & 0 deletions test/skills-export-import.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading