diff --git a/.changeset/add-copilot-cloud-agent-files.md b/.changeset/add-copilot-cloud-agent-files.md new file mode 100644 index 0000000000..185f26fa76 --- /dev/null +++ b/.changeset/add-copilot-cloud-agent-files.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Generate GitHub Copilot coding agent setup and custom agent files during `openspec init` and keep them synchronized during `openspec update`. diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts new file mode 100644 index 0000000000..c46b4175e0 --- /dev/null +++ b/src/core/github-copilot/cloud-agent.ts @@ -0,0 +1,484 @@ +/** + * GitHub Copilot Cloud Agent Support + * + * Generates copilot-setup-steps.yml and .github/agents/openspec.agent.md + * when the github-copilot tool is selected during init/update. + * These files enable the GitHub Copilot coding agent (cloud) to use the + * OpenSpec CLI in its ephemeral dev environment. + */ + +import path from 'path'; +import { promises as fs } from 'fs'; +import { FileSystemUtils } from '../../utils/file-system.js'; + +const COPILOT_TOOL_ID = 'github-copilot'; +const OPENSPEC_MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; + +/** + * Check if a tool list includes github-copilot. + */ +export function includesGitHubCopilot(toolIds: string[]): boolean { + return toolIds.includes(COPILOT_TOOL_ID); +} + +/** + * Generate the copilot-setup-steps.yml workflow file content. + * This workflow pre-installs the OpenSpec CLI in the Copilot coding agent's + * ephemeral GitHub Actions environment. + */ +export function generateCopilotSetupSteps(): string { + return `# ${OPENSPEC_MANAGED_MARKER} + +${generateCopilotSetupStepsBody()}`; +} + +function generateCopilotSetupStepsBody(): string { + return `name: "Copilot Setup Steps" + +# Runs automatically when changed (for validation) and can be triggered manually. +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called \`copilot-setup-steps\` for Copilot coding agent to pick it up. + copilot-setup-steps: + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install OpenSpec CLI + run: npm install -g @fission-ai/openspec + + - name: Verify OpenSpec CLI + run: openspec --version +`; +} + +/** + * Generate the .github/agents/openspec.agent.md custom agent file content. + * This tells the GitHub Copilot coding agent how to use the OpenSpec CLI. + */ +export function generateCopilotAgentFile(): string { + return generateCopilotAgentFileBody(true); +} + +function generateCopilotAgentFileBody(includeManagedMarker = false): string { + const managedMarker = includeManagedMarker + ? `\n\n` + : ''; + + return `--- +name: OpenSpec +description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work." +tools: + - "execute" + - "read" + - "search" + - "edit" +--- + +${managedMarker}# OpenSpec Agent + +You are a specialized agent for managing OpenSpec workflows. Before using the \`openspec\` CLI, run \`openspec --version\`. If it is unavailable, install it with \`npm install -g @fission-ai/openspec\`. + +## What is OpenSpec? + +OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation. + +## Available Commands + +### Agent-Compatible CLI Commands (prefer \`--json\` for structured output) + +| Command | Purpose | +|---------|---------| +| \`openspec list [--json]\` | List all changes and specs | +| \`openspec show [--json]\` | View a specific change or spec | +| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues | +| \`openspec status [--change ] [--json]\` | Show artifact progress for a change | +| \`openspec instructions [artifact] [--change ] [--json]\` | Get next-step instructions for a change | +| \`openspec templates [--json]\` | List available templates | +| \`openspec schemas [--json]\` | List available workflow schemas | +| \`openspec archive --json [--yes]\` | Archive a completed change; use \`--yes\` only after confirming all tasks are complete | + +### Interactive CLI Commands (use when prompted by the user) + +| Command | Purpose | +|---------|---------| +| \`openspec init\` | Initialize OpenSpec in the project | +| \`openspec update\` | Update OpenSpec configuration and artifacts | +| \`openspec view\` | Interactive dashboard | +| \`openspec config\` | View or modify settings | + +## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Find the change**: Run \`openspec list --json\` to see active changes. +2. **Check progress**: Run \`openspec status --change --json\` for the selected change. +3. **Follow instructions**: Run \`openspec instructions [artifact] --change --json\` for the next artifact. +4. **Validate before completing**: Run \`openspec validate --json\`. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Run \`openspec new change \`. +2. Run \`openspec status --change --json\` to see the artifact sequence. +3. Use \`openspec instructions [artifact] --change --json\` before creating each artifact. +4. Run \`openspec validate --json\` when the artifacts are complete. + +## Key Directories + +- \`openspec/\` — Root OpenSpec directory +- \`openspec/changes/\` — Active changes with their artifacts +- \`openspec/config.yaml\` — Project configuration + +## Best Practices + +- Always use \`--json\` flag when you need to parse output programmatically +- Run \`openspec validate\` after creating or modifying artifacts +- Check \`openspec status\` before starting work to understand the current state +- When archiving, ensure all tasks are completed and validated first +`; +} + +function generatePreviousCopilotAgentFileBody(includeManagedMarker = false): string { + let content = generateCopilotAgentFileBody(); + content = replaceRequired( + content, + 'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.', + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.', + 'previous CLI access sentence' + ); + content = replaceRequired( + content, + '| `openspec archive --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |', + '| `openspec archive ` | Archive a completed change |', + 'previous archive command row' + ); + + if (!includeManagedMarker) { + return content; + } + + return replaceRequired( + content, + '\n# OpenSpec Agent', + `\n\n\n# OpenSpec Agent`, + 'previous agent heading' + ); +} + +function generateLegacyCopilotAgentFileBody(): string { + let content = generatePreviousCopilotAgentFileBody(); + content = replaceRequired( + content, + `## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Find the change**: Run \`openspec list --json\` to see active changes. +2. **Check progress**: Run \`openspec status --change --json\` for the selected change. +3. **Follow instructions**: Run \`openspec instructions [artifact] --change --json\` for the next artifact. +4. **Validate before completing**: Run \`openspec validate --json\`. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Run \`openspec new change \`. +2. Run \`openspec status --change --json\` to see the artifact sequence. +3. Use \`openspec instructions [artifact] --change --json\` before creating each artifact. +4. Run \`openspec validate --json\` when the artifacts are complete.`, + `## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress. +2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps. +3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Create the change directory under \`openspec/changes//\` +2. Generate the required planning artifacts based on the project's configured workflow schema +3. Run \`openspec validate --json\` to verify the artifacts are well-formed`, + 'legacy workflow guidance' + ); + content = replaceRequired( + content, + `tools: + - "execute" + - "read" + - "search" + - "edit"`, + `tools: + - "terminal"`, + 'legacy tool alias' + ); + content = replaceRequired( + content, + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.', + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI which is pre-installed in the development environment via `copilot-setup-steps.yml`.', + 'legacy CLI access sentence' + ); + content = replaceRequired( + content, + '| `openspec status [--change ] [--json]` | Show artifact progress for a change |', + '| `openspec status [--json]` | Show artifact progress for active changes |', + 'legacy status command row' + ); + content = replaceRequired( + content, + '| `openspec instructions [artifact] [--change ] [--json]` | Get next-step instructions for a change |', + '| `openspec instructions [--json]` | Get next-step instructions for a change |', + 'legacy instructions command row' + ); + return replaceRequired( + content, + '- `openspec/config.yaml` — Project configuration', + `- \`openspec/config.yaml\` — Project configuration +- \`openspec/explorations/\` — Exploration documents`, + 'legacy exploration directory' + ); +} + +function replaceRequired( + content: string, + searchValue: string, + replaceValue: string, + label: string +): string { + if (!content.includes(searchValue)) { + throw new Error(`Cannot build Copilot cloud file content: missing ${label}`); + } + return content.replace(searchValue, replaceValue); +} + +/** + * File paths (relative to project root) for the generated files. + */ +export const COPILOT_CLOUD_FILES = { + setupSteps: path.join('.github', 'workflows', 'copilot-setup-steps.yml'), + agent: path.join('.github', 'agents', 'openspec.agent.md'), +} as const; + +const COPILOT_AGENT_ALTERNATE_FILE = path.join('.github', 'agents', 'openspec.md'); + +type CopilotCloudFile = (typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES]; + +const COPILOT_CLOUD_FILE_CONTENTS: Record = { + [COPILOT_CLOUD_FILES.setupSteps]: generateCopilotSetupSteps(), + [COPILOT_CLOUD_FILES.agent]: generateCopilotAgentFile(), +}; + +function getLegacyCopilotCloudFileContents(relPath: CopilotCloudFile): string[] { + if (relPath === COPILOT_CLOUD_FILES.setupSteps) { + return [generateCopilotSetupStepsBody()]; + } + + return [ + generateCopilotAgentFileBody(), + generatePreviousCopilotAgentFileBody(), + generatePreviousCopilotAgentFileBody(true), + generateLegacyCopilotAgentFileBody(), + ]; +} + +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n/g, '\n'); +} + +function isCurrentCopilotCloudFile( + relPath: CopilotCloudFile, + content: string +): boolean { + return normalizeLineEndings(content) === COPILOT_CLOUD_FILE_CONTENTS[relPath]; +} + +function isLegacyCopilotCloudFile( + relPath: CopilotCloudFile, + content: string +): boolean { + return getLegacyCopilotCloudFileContents(relPath).includes(normalizeLineEndings(content)); +} + +function isManagedCopilotCloudFile( + relPath: CopilotCloudFile, + content: string +): boolean { + return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content); +} + +async function reconcileCopilotCloudFile( + fullPath: string, + relPath: CopilotCloudFile +): Promise { + const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath]; + + if (!(await FileSystemUtils.fileExists(fullPath))) { + await FileSystemUtils.writeFile(fullPath, currentContent); + return true; + } + + const existingContent = await FileSystemUtils.readFile(fullPath); + if (isCurrentCopilotCloudFile(relPath, existingContent)) { + return false; + } + if (!isLegacyCopilotCloudFile(relPath, existingContent)) { + return false; + } + + await FileSystemUtils.writeFile(fullPath, currentContent); + return true; +} + +async function assertCreatableFilePath(filePath: string): Promise { + let candidate = path.dirname(filePath); + + while (true) { + try { + const stats = await fs.stat(candidate); + if (!stats.isDirectory()) { + throw new Error(`Parent path is not a directory: ${candidate}`); + } + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const parent = path.dirname(candidate); + if (parent === candidate) { + throw new Error(`Cannot resolve a directory ancestor for: ${filePath}`); + } + candidate = parent; + } +} + +async function assertMissingOrRegularFile(filePath: string): Promise { + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + throw new Error(`Managed Copilot path is not a regular file: ${filePath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } +} + +async function classifyCopilotAgentReconciliation( + agentPath: string, + alternateAgentPath: string +): Promise<'reconcile' | 'skip' | 'remove-managed'> { + if (!(await FileSystemUtils.fileExists(alternateAgentPath))) { + return 'reconcile'; + } + if (!(await FileSystemUtils.fileExists(agentPath))) { + return 'skip'; + } + + const existingContent = await FileSystemUtils.readFile(agentPath); + if (isManagedCopilotCloudFile(COPILOT_CLOUD_FILES.agent, existingContent)) { + return 'remove-managed'; + } + + throw new Error( + `Conflicting Copilot agent profiles: preserve either ${COPILOT_AGENT_ALTERNATE_FILE} or ${COPILOT_CLOUD_FILES.agent}` + ); +} + +/** + * Reconcile Copilot cloud agent files in the project directory. + * Creates missing files and refreshes recognized legacy generated files while + * preserving current generated content and user customizations. + * + * @returns Object indicating which files were written. + */ +export async function writeCopilotCloudFiles( + projectPath: string +): Promise<{ setupStepsWritten: boolean; agentWritten: boolean }> { + const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath( + projectPath, + COPILOT_CLOUD_FILES.setupSteps + ); + const agentPath = FileSystemUtils.resolveProjectArtifactPath( + projectPath, + COPILOT_CLOUD_FILES.agent + ); + const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath( + projectPath, + COPILOT_AGENT_ALTERNATE_FILE + ); + + await assertCreatableFilePath(setupStepsPath); + await assertCreatableFilePath(agentPath); + await assertMissingOrRegularFile(setupStepsPath); + await assertMissingOrRegularFile(agentPath); + await assertMissingOrRegularFile(alternateAgentPath); + const agentReconciliation = await classifyCopilotAgentReconciliation( + agentPath, + alternateAgentPath + ); + + const setupStepsWritten = await reconcileCopilotCloudFile( + setupStepsPath, + COPILOT_CLOUD_FILES.setupSteps + ); + let agentWritten = false; + if (agentReconciliation === 'reconcile') { + agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent); + } else if (agentReconciliation === 'remove-managed') { + await fs.unlink(agentPath); + } + + return { setupStepsWritten, agentWritten }; +} + +/** + * Remove copilot cloud agent files from the project directory. + * Used when github-copilot is deselected. + * + * @returns Number of files removed. + */ +export async function removeCopilotCloudFiles(projectPath: string): Promise { + let removed = 0; + const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({ + relPath, + fullPath: FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath), + })); + for (const { fullPath } of managedPaths) { + await assertMissingOrRegularFile(fullPath); + } + + for (const { relPath, fullPath } of managedPaths) { + if (await FileSystemUtils.fileExists(fullPath)) { + const content = await FileSystemUtils.readFile(fullPath); + if (!isManagedCopilotCloudFile(relPath, content)) { + continue; + } + + await fs.unlink(fullPath); + removed++; + } + } + + return removed; +} diff --git a/src/core/init.ts b/src/core/init.ts index 037024162b..de451d0dea 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -60,6 +60,7 @@ import { shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js'; +import { writeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -769,6 +770,9 @@ export class InitCommand { if (shouldReconcileCommandFilesForTool(tool.value, delivery)) { removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); } + if (tool.value === 'github-copilot') { + await writeCopilotCloudFiles(projectPath); + } spinner.succeed(`Setup complete for ${tool.name}`); diff --git a/src/core/update.ts b/src/core/update.ts index 7c97803573..e1c9fdf758 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -67,6 +67,7 @@ import { shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js'; +import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -163,6 +164,7 @@ export class UpdateCommand { // 5. Find configured tools const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath); + const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])]; if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) { if (deferredGlobalCleanup) { @@ -184,6 +186,7 @@ export class UpdateCommand { } return; } + await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); console.log(chalk.yellow('No configured tools found.')); console.log(chalk.dim('Run "openspec init" to set up tools.')); return; @@ -221,6 +224,7 @@ export class UpdateCommand { } // All tools are up to date this.displayUpToDateMessage(toolStatuses); + await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); // Still check for new tool directories and extra workflows this.detectNewTools(resolvedProjectPath, configuredTools); @@ -430,7 +434,7 @@ export class UpdateCommand { console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } - const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])]; + await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); // 13. Detect new tool directories not currently configured this.detectNewTools(resolvedProjectPath, configuredAndNewTools); @@ -453,6 +457,23 @@ export class UpdateCommand { } } + private async syncCopilotCloudFiles(projectPath: string, configuredTools: string[]): Promise { + try { + if (includesGitHubCopilot(configuredTools)) { + await writeCopilotCloudFiles(projectPath); + return; + } + + const removed = await removeCopilotCloudFiles(projectPath); + if (removed > 0) { + console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`)); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Warning: failed to sync Copilot cloud agent files: ${message}`); + } + } + /** * Display message when all tools are up to date. */ diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts new file mode 100644 index 0000000000..70891c2d6f --- /dev/null +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -0,0 +1,550 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { promises as fs } from 'fs'; +import { parse } from 'yaml'; +import { + includesGitHubCopilot, + generateCopilotSetupSteps, + generateCopilotAgentFile, + COPILOT_CLOUD_FILES, + removeCopilotCloudFiles, + writeCopilotCloudFiles, +} from '../../src/core/github-copilot/cloud-agent.js'; + +const MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; +const MARKERLESS_LEGACY_COPILOT_AGENT_FILE = `--- +name: OpenSpec +description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work." +tools: + - "terminal" +--- + +# OpenSpec Agent + +You are a specialized agent for managing OpenSpec workflows. You have access to the \`openspec\` CLI which is pre-installed in the development environment via \`copilot-setup-steps.yml\`. + +## What is OpenSpec? + +OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation. + +## Available Commands + +### Agent-Compatible CLI Commands (prefer \`--json\` for structured output) + +| Command | Purpose | +|---------|---------| +| \`openspec list [--json]\` | List all changes and specs | +| \`openspec show [--json]\` | View a specific change or spec | +| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues | +| \`openspec status [--json]\` | Show artifact progress for active changes | +| \`openspec instructions [--json]\` | Get next-step instructions for a change | +| \`openspec templates [--json]\` | List available templates | +| \`openspec schemas [--json]\` | List available workflow schemas | +| \`openspec archive \` | Archive a completed change | + +### Interactive CLI Commands (use when prompted by the user) + +| Command | Purpose | +|---------|---------| +| \`openspec init\` | Initialize OpenSpec in the project | +| \`openspec update\` | Update OpenSpec configuration and artifacts | +| \`openspec view\` | Interactive dashboard | +| \`openspec config\` | View or modify settings | + +## Workflow + +When asked to work with OpenSpec, follow this pattern: + +1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress. +2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps. +3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct. + +## Creating New Changes + +When the user wants to propose a new change: + +1. Create the change directory under \`openspec/changes//\` +2. Generate the required planning artifacts based on the project's configured workflow schema +3. Run \`openspec validate --json\` to verify the artifacts are well-formed + +## Key Directories + +- \`openspec/\` \u2014 Root OpenSpec directory +- \`openspec/changes/\` \u2014 Active changes with their artifacts +- \`openspec/config.yaml\` \u2014 Project configuration +- \`openspec/explorations/\` \u2014 Exploration documents + +## Best Practices + +- Always use \`--json\` flag when you need to parse output programmatically +- Run \`openspec validate\` after creating or modifying artifacts +- Check \`openspec status\` before starting work to understand the current state +- When archiving, ensure all tasks are completed and validated first +`; + +describe('GitHub Copilot Cloud Agent', () => { + let tempDir: string; + + function removeManagedMarker(content: string): string { + const withoutMarker = content + .replace(/^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, '') + .replace(/\n\n/, ''); + expect(withoutMarker).not.toBe(content); + expect(withoutMarker).not.toContain(MANAGED_MARKER); + return withoutMarker; + } + + function withCrLf(content: string): string { + return content.replace(/\n/g, '\r\n'); + } + + async function linkDirectoryOutsideProject(outsideDir: string): Promise { + await fs.symlink( + outsideDir, + path.join(tempDir, '.github'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + } + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-cloud-agent-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('includesGitHubCopilot', () => { + it('returns true when github-copilot is in the list', () => { + expect(includesGitHubCopilot(['claude', 'github-copilot', 'cursor'])).toBe(true); + }); + + it('returns false when github-copilot is not in the list', () => { + expect(includesGitHubCopilot(['claude', 'cursor'])).toBe(false); + }); + + it('returns false for empty list', () => { + expect(includesGitHubCopilot([])).toBe(false); + }); + }); + + describe('generateCopilotSetupSteps', () => { + it('generates a structurally valid Copilot setup workflow', () => { + const content = generateCopilotSetupSteps(); + const workflow = parse(content); + + expect(workflow).toMatchObject({ + name: 'Copilot Setup Steps', + on: { + workflow_dispatch: null, + push: { paths: ['.github/workflows/copilot-setup-steps.yml'] }, + pull_request: { paths: ['.github/workflows/copilot-setup-steps.yml'] }, + }, + jobs: { + 'copilot-setup-steps': { + 'runs-on': 'ubuntu-latest', + 'timeout-minutes': 10, + permissions: { contents: 'read' }, + }, + }, + }); + expect(Object.keys(workflow.jobs)).toEqual(['copilot-setup-steps']); + expect(workflow.jobs['copilot-setup-steps'].steps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ run: 'npm install -g @fission-ai/openspec' }), + expect.objectContaining({ run: 'openspec --version' }), + ]) + ); + }); + }); + + describe('generateCopilotAgentFile', () => { + it('generates valid agent frontmatter and non-interactive guidance', () => { + const content = generateCopilotAgentFile(); + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + expect(frontmatterMatch).not.toBeNull(); + const frontmatter = parse(frontmatterMatch![1]); + + expect(frontmatter).toEqual({ + name: 'OpenSpec', + description: expect.any(String), + tools: ['execute', 'read', 'search', 'edit'], + }); + expect(content).toContain('Generated by OpenSpec for GitHub Copilot coding agent support.'); + expect(content).toContain('# OpenSpec Agent'); + expect(content).toContain('openspec list'); + expect(content).toContain('openspec new change '); + expect(content).toContain('openspec status --change --json'); + expect(content).toContain('openspec instructions [artifact] --change --json'); + expect(content).toContain('openspec archive --json [--yes]'); + expect(content).toContain('use `--yes` only after confirming all tasks are complete'); + expect(content).toContain('run `openspec --version`'); + expect(content).not.toContain('pre-installed in the development environment'); + expect(content).not.toContain('Create the change directory under'); + expect(content).toContain('openspec validate'); + }); + }); + + describe('COPILOT_CLOUD_FILES', () => { + it('has correct file paths', () => { + expect(COPILOT_CLOUD_FILES.setupSteps).toBe(path.join('.github', 'workflows', 'copilot-setup-steps.yml')); + expect(COPILOT_CLOUD_FILES.agent).toBe(path.join('.github', 'agents', 'openspec.agent.md')); + }); + }); + + describe('writeCopilotCloudFiles', () => { + it('writes missing cloud files and creates parent directories', async () => { + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: true, agentWritten: true }); + await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps))).resolves.toBeTruthy(); + await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.agent))).resolves.toBeTruthy(); + }); + + it('preserves customized existing files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'custom setup'); + await fs.writeFile(agentPath, 'custom agent'); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: false }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent'); + }); + + it('creates robust agent guidance alongside a customized setup workflow', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customSetup = 'name: custom setup\n'; + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, customSetup); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: true }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(customSetup); + const agentContent = await fs.readFile(agentPath, 'utf8'); + expect(agentContent).toContain('run `openspec --version`'); + expect(agentContent).toContain('install it with `npm install -g @fission-ai/openspec`'); + expect(agentContent).not.toContain('pre-installed in the development environment'); + }); + + it('preserves an alternate user-owned agent with the same Copilot identifier', async () => { + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customAgent = 'user-owned OpenSpec agent\n'; + await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true }); + await fs.writeFile(alternateAgentPath, customAgent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: true, agentWritten: false }); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent); + await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('removes a managed agent when an alternate user-owned agent is added later', async () => { + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customAgent = 'user-owned OpenSpec agent\n'; + await writeCopilotCloudFiles(tempDir); + await fs.writeFile(alternateAgentPath, customAgent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: false }); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent); + await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reports conflicting user-owned agent profiles without creating setup files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true }); + await fs.writeFile(alternateAgentPath, 'custom alternate agent\n'); + await fs.writeFile(generatedAgentPath, 'custom generated-path agent\n'); + + await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Conflicting Copilot agent profiles' + ); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe( + 'custom alternate agent\n' + ); + await expect(fs.readFile(generatedAgentPath, 'utf8')).resolves.toBe( + 'custom generated-path agent\n' + ); + }); + + it('rejects a directory at a managed file path before creating other files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(agentPath, { recursive: true }); + + await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Managed Copilot path is not a regular file' + ); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await fs.stat(agentPath)).isDirectory()).toBe(true); + }); + + it('refreshes exact legacy generated files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: true, agentWritten: true }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe( + generateCopilotSetupSteps() + ); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile()); + }); + + it('refreshes the previous marker-bearing generated agent', async () => { + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const previousAgent = generateCopilotAgentFile() + .replace( + 'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.', + 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.' + ) + .replace( + '| `openspec archive --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |', + '| `openspec archive ` | Archive a completed change |' + ); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(agentPath, previousAgent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result.agentWritten).toBe(true); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile()); + }); + + it('leaves current generated files unchanged, including CRLF content', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const setupStepsContent = generateCopilotSetupSteps(); + const agentContent = withCrLf(generateCopilotAgentFile()); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, setupStepsContent); + await fs.writeFile(agentPath, agentContent); + + const result = await writeCopilotCloudFiles(tempDir); + + expect(result).toEqual({ setupStepsWritten: false, agentWritten: false }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(agentContent); + }); + + it('refuses to write cloud files through a linked .github directory', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-')); + const outsideSetupStepsPath = path.join( + outsideDir, + 'workflows', + 'copilot-setup-steps.yml' + ); + const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md'); + + try { + await linkDirectoryOutsideProject(outsideDir); + + await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.stat(outsideSetupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(outsideAgentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + + describe('removeCopilotCloudFiles', () => { + it('removes only existing cloud files and returns the removal count', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await writeCopilotCloudFiles(tempDir); + await fs.rm(agentPath, { force: true }); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(1); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps customized cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'custom setup'); + await fs.writeFile(agentPath, 'custom agent'); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent'); + }); + + it('keeps modified marker-bearing cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, `${generateCopilotSetupSteps()}\n# custom change\n`); + await fs.writeFile(agentPath, `${generateCopilotAgentFile()}\ncustom change\n`); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('custom change'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('custom change'); + }); + + it('removes markerless current generated cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, removeManagedMarker(generateCopilotAgentFile())); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(2); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('removes markerless legacy generated cloud files', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const currentAgentContent = removeManagedMarker(generateCopilotAgentFile()); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).not.toBe(currentAgentContent); + expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).toContain(' - "terminal"'); + expect(removed).toBe(2); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('removes current and legacy generated cloud files with CRLF line endings', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(setupStepsPath, withCrLf(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, withCrLf(MARKERLESS_LEGACY_COPILOT_AGENT_FILE)); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(2); + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps customized cloud files with CRLF line endings', async () => { + const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent); + const customizedContent = withCrLf(`${generateCopilotAgentFile()}\ncustom change\n`); + await fs.mkdir(path.dirname(agentPath), { recursive: true }); + await fs.writeFile(agentPath, customizedContent); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customizedContent); + }); + + it('preserves the alternate user-owned agent during cleanup', async () => { + const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md'); + const customAgent = 'user-owned OpenSpec agent\n'; + await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true }); + await fs.writeFile(alternateAgentPath, customAgent); + + const removed = await removeCopilotCloudFiles(tempDir); + + expect(removed).toBe(0); + await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent); + }); + + it('preflights nested linked paths before removing any managed file', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + const agentsDir = path.join(tempDir, '.github', 'agents'); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-')); + const outsideAgentPath = path.join(outsideDir, 'openspec.agent.md'); + const setupStepsContent = generateCopilotSetupSteps(); + const agentContent = generateCopilotAgentFile(); + + try { + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, setupStepsContent); + await fs.writeFile(outsideAgentPath, agentContent); + await fs.symlink( + outsideDir, + agentsDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent); + await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('refuses to remove managed cloud files through a linked .github directory', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-')); + const outsideSetupStepsPath = path.join( + outsideDir, + 'workflows', + 'copilot-setup-steps.yml' + ); + const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md'); + const setupStepsContent = generateCopilotSetupSteps(); + const agentContent = generateCopilotAgentFile(); + + try { + await fs.mkdir(path.dirname(outsideSetupStepsPath), { recursive: true }); + await fs.mkdir(path.dirname(outsideAgentPath), { recursive: true }); + await fs.writeFile(outsideSetupStepsPath, setupStepsContent); + await fs.writeFile(outsideAgentPath, agentContent); + await linkDirectoryOutsideProject(outsideDir); + + await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow( + 'Path is outside the allowed directory' + ); + await expect(fs.readFile(outsideSetupStepsPath, 'utf8')).resolves.toBe( + setupStepsContent + ); + await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 308d9a8b74..8a873ba142 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -172,6 +172,27 @@ describe('InitCommand', () => { ); }); + it('should not create Copilot cloud files when GitHub Copilot setup fails', async () => { + const outsideDir = path.join(configTempDir, 'outside-github'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(testDir, '.github'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: GitHub Copilot' + ); + + expect(await fs.readdir(outsideDir)).toEqual([]); + expect((await fs.lstat(path.join(testDir, '.github'))).isSymbolicLink()).toBe(true); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'OpenSpec Setup Incomplete' + ); + }); + it.skipIf(process.platform === 'win32')('should not overwrite a generated artifact symlink outside the project', async () => { const outsideFile = path.join(configTempDir, 'outside-skill.md'); const originalContent = 'keep me\n'; @@ -874,6 +895,28 @@ describe('InitCommand', () => { const cmdFile = path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md'); expect(await fileExists(cmdFile)).toBe(true); }); + + it('should fail GitHub Copilot setup without partially creating cloud files', async () => { + const agentsPath = path.join(testDir, '.github', 'agents'); + const setupStepsPath = path.join( + testDir, + '.github', + 'workflows', + 'copilot-setup-steps.yml' + ); + await fs.mkdir(path.dirname(agentsPath), { recursive: true }); + await fs.writeFile(agentsPath, 'blocks the generated agent directory'); + + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: GitHub Copilot' + ); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'OpenSpec Setup Incomplete' + ); + }); }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 2670a552b0..d8a8b57a4c 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -4,6 +4,7 @@ import { InitCommand } from '../../src/core/init.js'; import { FileSystemUtils } from '../../src/utils/file-system.js'; import { OPENSPEC_MARKERS } from '../../src/core/config.js'; import type { GlobalConfig } from '../../src/core/global-config.js'; +import { generateCopilotSetupSteps } from '../../src/core/github-copilot/cloud-agent.js'; import path from 'path'; import fs from 'fs/promises'; import os from 'os'; @@ -94,6 +95,20 @@ describe('UpdateCommand', () => { consoleSpy.mockRestore(); }); + + it('should remove generated Copilot cloud files when no tools are configured', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + await fs.rm(path.join(testDir, '.github', 'skills'), { recursive: true, force: true }); + await fs.rm(path.join(testDir, '.github', 'prompts'), { recursive: true, force: true }); + + await updateCommand.execute(testDir); + + await expect(fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'))) + .rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(path.join(testDir, '.github', 'agents', 'openspec.agent.md'))) + .rejects.toMatchObject({ code: 'ENOENT' }); + }); }); describe('skill updates', () => { @@ -1189,6 +1204,59 @@ metadata: consoleSpy.mockRestore(); }); + it('should create GitHub Copilot cloud files when github-copilot is up to date', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const agentPath = path.join(testDir, '.github', 'agents', 'openspec.agent.md'); + await fs.rm(setupStepsPath, { force: true }); + await fs.rm(agentPath, { force: true }); + + await updateCommand.execute(testDir); + + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('copilot-setup-steps:'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('# OpenSpec Agent'); + }); + + it('should refresh managed legacy Copilot files and preserve custom files during force update', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const agentPath = path.join(testDir, '.github', 'agents', 'openspec.agent.md'); + const legacySetupSteps = generateCopilotSetupSteps().replace( + /^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, + '' + ); + const customAgent = 'custom Copilot agent'; + await fs.writeFile(setupStepsPath, legacySetupSteps); + await fs.writeFile(agentPath, customAgent); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe( + generateCopilotSetupSteps() + ); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customAgent); + }); + + it('should warn when GitHub Copilot cloud files cannot be synchronized', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + const agentsPath = path.join(testDir, '.github', 'agents'); + await fs.rm(agentsPath, { recursive: true, force: true }); + await fs.writeFile(agentsPath, 'blocks the generated agent directory'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await updateCommand.execute(testDir); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to sync Copilot cloud agent files') + ); + }); + it('should detect update needed when generatedBy is missing', async () => { // Set up a configured tool without generatedBy const skillsDir = path.join(testDir, '.claude', 'skills');