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
6 changes: 6 additions & 0 deletions .changeset/repo-import-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@bradygaster/squad-cli': minor
'@bradygaster/squad-sdk': minor
---

Add `--repo` and `--branch` flags to `squad export` and `squad import` commands, enabling users to push/pull Squad configuration directly to/from a GitHub repository via the GitHub Contents API.
26 changes: 20 additions & 6 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,18 +704,32 @@ async function main(): Promise<void> {
const { runExport } = await import('./cli/commands/export.js');
const outIdx = args.indexOf('--out');
const outPath = (outIdx !== -1 && args[outIdx + 1]) ? args[outIdx + 1] : undefined;
await runExport(getSquadStartDir(), outPath);
const repoIdx = args.indexOf('--repo');
const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined;
const branchIdx = args.indexOf('--branch');
const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined;
const repoOptions = repoArg ? { repo: repoArg, branch: branchArg } : undefined;
await runExport(getSquadStartDir(), outPath, repoOptions);
return;
}

if (cmd === 'import') {
const { runImport } = await import('./cli/commands/import.js');
const importFile = args[1];
if (!importFile) {
fatal('Usage: squad import <file> [--force]');
}
const repoIdx = args.indexOf('--repo');
const repoArg = (repoIdx !== -1 && args[repoIdx + 1]) ? args[repoIdx + 1] : undefined;
const branchIdx = args.indexOf('--branch');
const branchArg = (branchIdx !== -1 && args[branchIdx + 1]) ? args[branchIdx + 1] : undefined;
const hasForce = args.includes('--force');
await runImport(getSquadStartDir(), importFile, hasForce);
if (repoArg) {
const repoOptions = { repo: repoArg, branch: branchArg };
await runImport(getSquadStartDir(), '', hasForce, repoOptions);
} else {
const importFile = args[1];
if (!importFile) {
fatal('Usage: squad import <file> [--force] or squad import --repo owner/repo [--branch branch] [--force]');
}
await runImport(getSquadStartDir(), importFile, hasForce);
}
return;
}

Expand Down
93 changes: 77 additions & 16 deletions packages/squad-cli/src/cli/commands/export.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
/**
* Export command — port from beta CLI
* Exports squad to squad-export.json
* Exports squad to squad-export.json (local or GitHub repo)
*/

import path from 'node:path';
import { FSStorageProvider } from '@bradygaster/squad-sdk';
import { FSStorageProvider, exportToRepo, parseRepoString } from '@bradygaster/squad-sdk';
import type { RepoSpec } from '@bradygaster/squad-sdk';
import { detectSquadDir } from '../core/detect-squad-dir.js';
import { success, warn } from '../core/output.js';
import { success, warn, info } from '../core/output.js';
import { fatal } from '../core/errors.js';
import { ghAvailable, ghAuthenticated } from '../core/gh-cli.js';

interface ExportManifest {
version: string;
exported_at: string;
squad_version: string;
team_md?: string;
decisions_md?: string;
routing_md?: string;
casting: Record<string, unknown>;
agents: Record<string, { charter?: string; history?: string }>;
skills: string[];
Comment thread
bradygaster marked this conversation as resolved.
Resolved
}

export interface ExportRepoOptions {
repo: string;
branch?: string;
}

/**
* Export squad to JSON
* Build the export manifest from a local squad directory.
*/
export async function runExport(dest: string, outPath?: string): Promise<void> {
const storage = new FSStorageProvider();
const squadInfo = detectSquadDir(dest);
const teamMd = path.join(squadInfo.path, 'team.md');

if (!storage.existsSync(teamMd)) {
fatal('No squad found — run init first');
}

function buildManifest(dest: string, storage: FSStorageProvider, squadInfo: { path: string }): ExportManifest {
const manifest: ExportManifest = {
version: '1.0',
exported_at: new Date().toISOString(),
Expand All @@ -39,6 +41,19 @@ export async function runExport(dest: string, outPath?: string): Promise<void> {
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;

const decisionsMdPath = path.join(squadInfo.path, 'decisions.md');
const decisionsMdContent = storage.readSync(decisionsMdPath);
if (decisionsMdContent !== undefined) manifest.decisions_md = decisionsMdContent;

const routingMdPath = path.join(squadInfo.path, 'routing.md');
const routingMdContent = storage.readSync(routingMdPath);
if (routingMdContent !== undefined) manifest.routing_md = routingMdContent;

// Read casting state
const castingDir = path.join(squadInfo.path, 'casting');
for (const file of ['registry.json', 'policy.json', 'history.json']) {
Expand All @@ -59,7 +74,6 @@ export async function runExport(dest: string, outPath?: string): Promise<void> {
if (storage.existsSync(agentsDir)) {
for (const entry of storage.listSync(agentsDir)) {
const agentDir = path.join(agentsDir, entry);
// Check if entry is a directory using StorageProvider
if (!storage.isDirectorySync(agentDir)) continue;
const agent: { charter?: string; history?: string } = {};
const charterPath = path.join(agentDir, 'charter.md');
Expand Down Expand Up @@ -98,11 +112,58 @@ export async function runExport(dest: string, outPath?: string): Promise<void> {
console.error(`Warning: could not read skills: ${(err as Error).message}`);
}

// Determine output path
return manifest;
}

/**
* Export squad to JSON (local file or GitHub repo)
*/
export async function runExport(dest: string, outPath?: string, repoOptions?: ExportRepoOptions): Promise<void> {
const storage = new FSStorageProvider();
const squadInfo = detectSquadDir(dest);
const teamMd = path.join(squadInfo.path, 'team.md');

if (!storage.existsSync(teamMd)) {
fatal('No squad found — run init first');
}

const manifest = buildManifest(dest, storage, squadInfo);
const bundleJson = JSON.stringify(manifest, null, 2) + '\n';

// Export to GitHub repo if --repo is specified
if (repoOptions?.repo) {
if (!(await ghAvailable())) {
fatal('GitHub CLI (gh) is required for repo export. Install from https://cli.github.com');
}
if (!(await ghAuthenticated())) {
fatal('GitHub CLI is not authenticated. Run: gh auth login');
}

const parsed = parseRepoString(repoOptions.repo);
const repoSpec: RepoSpec = {
owner: parsed.owner,
repo: parsed.repo,
branch: repoOptions.branch,
};

try {
const result = await exportToRepo(bundleJson, repoSpec, {
branch: repoOptions.branch,
});
success(result.message);
} catch (err) {
fatal(`Failed to export to repo: ${(err as Error).message}`);
}

warn('Review agent histories before sharing — they may contain project-specific information');
return;
}

// Local file export (existing behavior)
const finalOutPath = outPath || path.join(dest, 'squad-export.json');

try {
storage.writeSync(finalOutPath, JSON.stringify(manifest, null, 2) + '\n');
storage.writeSync(finalOutPath, bundleJson);
} catch (err) {
fatal(`Failed to write export file: ${(err as Error).message}`);
}
Expand Down
Loading
Loading