From 26363bcaeca0f1f088ebc19ba27a53723f410139 Mon Sep 17 00:00:00 2001 From: Nico <35104310+NicoAvanzDev@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:59:34 +0000 Subject: [PATCH 1/8] feat: generate copilot cloud agent files when github-copilot tool is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `openspec init` or `openspec update` is run with the github-copilot tool selected, two additional files are now generated in the user's project: 1. `.github/workflows/copilot-setup-steps.yml` - A GitHub Actions workflow that pre-installs the OpenSpec CLI in the Copilot coding agent's ephemeral environment (required for the agent to use `openspec` commands). 2. `.github/agents/openspec.agent.md` - A custom agent definition that instructs the GitHub Copilot coding agent how to use the OpenSpec CLI, including all agent-compatible commands with `--json` output, workflow patterns, and best practices. These files are only written if they don't already exist (to preserve user customizations). The generation is non-fatal — if it fails, init/update still completes successfully. New module: src/core/github-copilot/cloud-agent.ts Tests: test/core/github-copilot-cloud-agent.test.ts --- src/core/github-copilot/cloud-agent.ts | 196 +++++++++++++++++++ src/core/init.ts | 10 + src/core/update.ts | 10 + test/core/github-copilot-cloud-agent.test.ts | 54 +++++ 4 files changed, 270 insertions(+) create mode 100644 src/core/github-copilot/cloud-agent.ts create mode 100644 test/core/github-copilot-cloud-agent.test.ts diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts new file mode 100644 index 0000000000..10a17605be --- /dev/null +++ b/src/core/github-copilot/cloud-agent.ts @@ -0,0 +1,196 @@ +/** + * 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 { FileSystemUtils } from '../../utils/file-system.js'; + +const COPILOT_TOOL_ID = 'github-copilot'; + +/** + * 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 `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 `--- +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/\` — Root OpenSpec directory +- \`openspec/changes/\` — Active changes with their artifacts +- \`openspec/config.yaml\` — Project configuration +- \`openspec/explorations/\` — 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 +`; +} + +/** + * 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; + +/** + * Write copilot cloud agent files to the project directory. + * Only writes if the files don't already exist (to avoid overwriting user customizations). + * + * @returns Object indicating which files were written. + */ +export async function writeCopilotCloudFiles( + projectPath: string, + options?: { force?: boolean } +): Promise<{ setupStepsWritten: boolean; agentWritten: boolean }> { + const force = options?.force ?? false; + let setupStepsWritten = false; + let agentWritten = false; + + const setupStepsPath = path.join(projectPath, COPILOT_CLOUD_FILES.setupSteps); + const agentPath = path.join(projectPath, COPILOT_CLOUD_FILES.agent); + + // Write copilot-setup-steps.yml + if (force || !(await FileSystemUtils.fileExists(setupStepsPath))) { + await FileSystemUtils.writeFile(setupStepsPath, generateCopilotSetupSteps()); + setupStepsWritten = true; + } + + // Write openspec.agent.md + if (force || !(await FileSystemUtils.fileExists(agentPath))) { + await FileSystemUtils.writeFile(agentPath, generateCopilotAgentFile()); + agentWritten = true; + } + + 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; + + for (const relPath of Object.values(COPILOT_CLOUD_FILES)) { + const fullPath = path.join(projectPath, relPath); + if (await FileSystemUtils.fileExists(fullPath)) { + const fs = await import('fs'); + await fs.promises.unlink(fullPath); + removed++; + } + } + + return removed; +} diff --git a/src/core/init.ts b/src/core/init.ts index 7f5149dd46..5436cd768d 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -47,6 +47,7 @@ import { getGlobalConfig, type Delivery, type Profile } from './global-config.js import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded } from './migration.js'; +import { includesGitHubCopilot, writeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -610,6 +611,15 @@ export class InitCommand { } } + // Generate GitHub Copilot coding agent cloud files if github-copilot is selected + if (includesGitHubCopilot(tools.map((t) => t.value))) { + try { + await writeCopilotCloudFiles(projectPath); + } catch { + // Non-fatal: don't block init if cloud agent files fail + } + } + return { createdTools, refreshedTools, diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b1..b50042bbc7 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -47,6 +47,7 @@ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js'; +import { includesGitHubCopilot, writeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -245,6 +246,15 @@ export class UpdateCommand { } } + // Generate GitHub Copilot coding agent cloud files if github-copilot is being updated + if (includesGitHubCopilot(toolsToUpdate)) { + try { + await writeCopilotCloudFiles(resolvedProjectPath); + } catch { + // Non-fatal + } + } + // 11. Summary console.log(); if (updatedTools.length > 0) { 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..23df2229ed --- /dev/null +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { + includesGitHubCopilot, + generateCopilotSetupSteps, + generateCopilotAgentFile, + COPILOT_CLOUD_FILES, +} from '../../src/core/github-copilot/cloud-agent.js'; + +describe('GitHub Copilot Cloud Agent', () => { + 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 valid YAML workflow content', () => { + const content = generateCopilotSetupSteps(); + expect(content).toContain('name: "Copilot Setup Steps"'); + expect(content).toContain('copilot-setup-steps:'); + expect(content).toContain('runs-on: ubuntu-latest'); + expect(content).toContain('npm install -g @fission-ai/openspec'); + expect(content).toContain('openspec --version'); + }); + }); + + describe('generateCopilotAgentFile', () => { + it('generates agent markdown with frontmatter', () => { + const content = generateCopilotAgentFile(); + expect(content).toContain('name: OpenSpec'); + expect(content).toContain('description:'); + expect(content).toContain('tools:'); + expect(content).toContain('terminal'); + expect(content).toContain('# OpenSpec Agent'); + expect(content).toContain('openspec list'); + expect(content).toContain('openspec validate'); + }); + }); + + describe('COPILOT_CLOUD_FILES', () => { + it('has correct file paths', () => { + expect(COPILOT_CLOUD_FILES.setupSteps).toContain('copilot-setup-steps.yml'); + expect(COPILOT_CLOUD_FILES.agent).toContain('openspec.agent.md'); + }); + }); +}); From ad94fa2c0aed9ea8980d38bfef7daf074e620258 Mon Sep 17 00:00:00 2001 From: Nico <35104310+NicoAvanzDev@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:22:11 +0000 Subject: [PATCH 2/8] fix: wire up removeCopilotCloudFiles in update flow When github-copilot is not in the configured tools during update, remove the cloud agent files (copilot-setup-steps.yml and openspec.agent.md) if they exist. --- src/core/update.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/update.ts b/src/core/update.ts index b50042bbc7..0398b5f5dd 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -47,7 +47,7 @@ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js'; -import { includesGitHubCopilot, writeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; +import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -253,6 +253,16 @@ export class UpdateCommand { } catch { // Non-fatal } + } else if (!includesGitHubCopilot(configuredTools)) { + // github-copilot is not configured at all — clean up cloud agent files if they exist + try { + const removed = await removeCopilotCloudFiles(resolvedProjectPath); + if (removed > 0) { + console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`)); + } + } catch { + // Non-fatal + } } // 11. Summary From 62989285d78787fcd804e34dfd867d4a27b2991e Mon Sep 17 00:00:00 2001 From: NicoAvanzDev <35104310+NicoAvanzDev@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:25:21 +0200 Subject: [PATCH 3/8] fix: refresh Copilot cloud agent restore --- src/core/github-copilot/cloud-agent.ts | 11 +-- src/core/update.ts | 37 +++++----- test/core/github-copilot-cloud-agent.test.ts | 74 +++++++++++++++++++- test/core/update.test.ts | 15 ++++ 4 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index 10a17605be..85bbd76102 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -67,12 +67,15 @@ export function generateCopilotAgentFile(): string { 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" + - "execute" + - "read" + - "search" + - "edit" --- # 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\`. +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\`. ## What is OpenSpec? @@ -87,8 +90,8 @@ OpenSpec is a structured change management system for codebases. It organizes wo | \`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 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 \` | Archive a completed change | diff --git a/src/core/update.ts b/src/core/update.ts index 0398b5f5dd..cb1bdcbe8c 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -153,6 +153,7 @@ export class UpdateCommand { if (!this.force && toolsToUpdateSet.size === 0) { // All tools are up to date this.displayUpToDateMessage(toolStatuses); + await this.syncCopilotCloudFiles(resolvedProjectPath, [...new Set([...configuredTools, ...newlyConfiguredTools])]); // Still check for new tool directories and extra workflows this.detectNewTools(resolvedProjectPath, configuredTools); @@ -246,25 +247,6 @@ export class UpdateCommand { } } - // Generate GitHub Copilot coding agent cloud files if github-copilot is being updated - if (includesGitHubCopilot(toolsToUpdate)) { - try { - await writeCopilotCloudFiles(resolvedProjectPath); - } catch { - // Non-fatal - } - } else if (!includesGitHubCopilot(configuredTools)) { - // github-copilot is not configured at all — clean up cloud agent files if they exist - try { - const removed = await removeCopilotCloudFiles(resolvedProjectPath); - if (removed > 0) { - console.log(chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (github-copilot not configured)`)); - } - } catch { - // Non-fatal - } - } - // 11. Summary console.log(); if (updatedTools.length > 0) { @@ -298,6 +280,7 @@ export class UpdateCommand { } const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])]; + await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools); // 13. Detect new tool directories not currently configured this.detectNewTools(resolvedProjectPath, configuredAndNewTools); @@ -316,6 +299,22 @@ export class UpdateCommand { console.log(chalk.dim('Restart your IDE for changes to take effect.')); } + 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 { + // Non-fatal: cloud agent support should not block update. + } + } + /** * 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 index 23df2229ed..28e66d2c18 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -1,12 +1,27 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { promises as fs } from 'fs'; import { includesGitHubCopilot, generateCopilotSetupSteps, generateCopilotAgentFile, COPILOT_CLOUD_FILES, + removeCopilotCloudFiles, + writeCopilotCloudFiles, } from '../../src/core/github-copilot/cloud-agent.js'; describe('GitHub Copilot Cloud Agent', () => { + let tempDir: string; + + 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); @@ -38,7 +53,7 @@ describe('GitHub Copilot Cloud Agent', () => { expect(content).toContain('name: OpenSpec'); expect(content).toContain('description:'); expect(content).toContain('tools:'); - expect(content).toContain('terminal'); + expect(content).toContain('execute'); expect(content).toContain('# OpenSpec Agent'); expect(content).toContain('openspec list'); expect(content).toContain('openspec validate'); @@ -51,4 +66,59 @@ describe('GitHub Copilot Cloud Agent', () => { expect(COPILOT_CLOUD_FILES.agent).toContain('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('skips existing files by default', 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('overwrites existing files when force is true', 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, { force: true }); + + expect(result).toEqual({ setupStepsWritten: true, agentWritten: true }); + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('copilot-setup-steps:'); + await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('# OpenSpec Agent'); + }); + }); + + 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 fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'custom setup'); + + 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' }); + }); + }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7ed..5f8cd4c33d 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -608,6 +608,21 @@ Old instructions content 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 detect update needed when generatedBy is missing', async () => { // Set up a configured tool without generatedBy const skillsDir = path.join(testDir, '.claude', 'skills'); From e0a33e491a0cb973526fd0edf66bdbbf332a19d7 Mon Sep 17 00:00:00 2001 From: NicoAvanzDev <35104310+NicoAvanzDev@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:42:39 +0200 Subject: [PATCH 4/8] fix: address Copilot cloud review feedback --- src/core/github-copilot/cloud-agent.ts | 10 ++++++++ src/core/update.ts | 5 ++-- test/core/github-copilot-cloud-agent.test.ts | 26 +++++++++++++++++--- test/core/update.test.ts | 14 +++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index 85bbd76102..aca6d61e22 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -145,6 +145,11 @@ export const COPILOT_CLOUD_FILES = { agent: path.join('.github', 'agents', 'openspec.agent.md'), } as const; +const COPILOT_CLOUD_FILE_CONTENTS: Record<(typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], string> = { + [COPILOT_CLOUD_FILES.setupSteps]: generateCopilotSetupSteps(), + [COPILOT_CLOUD_FILES.agent]: generateCopilotAgentFile(), +}; + /** * Write copilot cloud agent files to the project directory. * Only writes if the files don't already exist (to avoid overwriting user customizations). @@ -189,6 +194,11 @@ export async function removeCopilotCloudFiles(projectPath: string): Promise { expect(content).toContain('description:'); expect(content).toContain('tools:'); expect(content).toContain('execute'); + expect(content).toContain('read'); + expect(content).toContain('search'); + expect(content).toContain('edit'); expect(content).toContain('# OpenSpec Agent'); expect(content).toContain('openspec list'); expect(content).toContain('openspec validate'); @@ -62,8 +65,8 @@ describe('GitHub Copilot Cloud Agent', () => { describe('COPILOT_CLOUD_FILES', () => { it('has correct file paths', () => { - expect(COPILOT_CLOUD_FILES.setupSteps).toContain('copilot-setup-steps.yml'); - expect(COPILOT_CLOUD_FILES.agent).toContain('openspec.agent.md'); + 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')); }); }); @@ -111,8 +114,8 @@ describe('GitHub Copilot Cloud Agent', () => { 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 fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); - await fs.writeFile(setupStepsPath, 'custom setup'); + await writeCopilotCloudFiles(tempDir); + await fs.rm(agentPath, { force: true }); const removed = await removeCopilotCloudFiles(tempDir); @@ -120,5 +123,20 @@ describe('GitHub Copilot Cloud Agent', () => { 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'); + }); }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 5f8cd4c33d..a97bb13cb5 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -92,6 +92,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', () => { From 8349bb9962f19d6bb6458c38d6756fdcf2235668 Mon Sep 17 00:00:00 2001 From: NicoAvanzDev <35104310+NicoAvanzDev@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:15:03 +0200 Subject: [PATCH 5/8] fix: recognize legacy Copilot cloud files --- src/core/github-copilot/cloud-agent.ts | 59 ++++++++++++++- test/core/github-copilot-cloud-agent.test.ts | 79 ++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index aca6d61e22..61990c3c96 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -11,6 +11,7 @@ import path from 'path'; 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. @@ -25,6 +26,12 @@ export function includesGitHubCopilot(toolIds: string[]): boolean { * 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. @@ -63,6 +70,13 @@ jobs: * This tells the GitHub Copilot coding agent how to use the OpenSpec CLI. */ export function generateCopilotAgentFile(): string { + return generateCopilotAgentFileBody().replace( + '\n# OpenSpec Agent', + `\n\n\n# OpenSpec Agent` + ); +} + +function generateCopilotAgentFileBody(): string { 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." @@ -137,6 +151,31 @@ When the user wants to propose a new change: `; } +function generateLegacyCopilotAgentFileBody(): string { + return generateCopilotAgentFileBody() + .replace( + `tools: + - "execute" + - "read" + - "search" + - "edit"`, + `tools: + - "terminal"` + ) + .replace( + '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`.' + ) + .replace( + '| `openspec status [--change ] [--json]` | Show artifact progress for a change |', + '| `openspec status [--json]` | Show artifact progress for active changes |' + ) + .replace( + '| `openspec instructions [artifact] [--change ] [--json]` | Get next-step instructions for a change |', + '| `openspec instructions [--json]` | Get next-step instructions for a change |' + ); +} + /** * File paths (relative to project root) for the generated files. */ @@ -150,6 +189,24 @@ const COPILOT_CLOUD_FILE_CONTENTS: Record<(typeof COPILOT_CLOUD_FILES)[keyof typ [COPILOT_CLOUD_FILES.agent]: generateCopilotAgentFile(), }; +const COPILOT_CLOUD_LEGACY_FILE_CONTENTS: Record<(typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], string[]> = { + [COPILOT_CLOUD_FILES.setupSteps]: [generateCopilotSetupStepsBody()], + [COPILOT_CLOUD_FILES.agent]: [ + generateCopilotAgentFileBody(), + generateLegacyCopilotAgentFileBody(), + ], +}; + +function isManagedCopilotCloudFile( + relPath: (typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], + content: string +): boolean { + return ( + content === COPILOT_CLOUD_FILE_CONTENTS[relPath] || + COPILOT_CLOUD_LEGACY_FILE_CONTENTS[relPath].includes(content) + ); +} + /** * Write copilot cloud agent files to the project directory. * Only writes if the files don't already exist (to avoid overwriting user customizations). @@ -195,7 +252,7 @@ export async function removeCopilotCloudFiles(projectPath: string): Promise { let tempDir: string; + function removeManagedMarker(content: string): string { + return content + .replace(/^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, '') + .replace(/\n\n/, ''); + } + + function generateLegacyCopilotAgentFile(): string { + return removeManagedMarker(generateCopilotAgentFile()) + .replace( + `tools: + - "execute" + - "read" + - "search" + - "edit"`, + `tools: + - "terminal"` + ) + .replace( + '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`.' + ) + .replace( + '| `openspec status [--change ] [--json]` | Show artifact progress for a change |', + '| `openspec status [--json]` | Show artifact progress for active changes |' + ) + .replace( + '| `openspec instructions [artifact] [--change ] [--json]` | Get next-step instructions for a change |', + '| `openspec instructions [--json]` | Get next-step instructions for a change |' + ); + } + beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-cloud-agent-')); }); @@ -39,6 +70,7 @@ describe('GitHub Copilot Cloud Agent', () => { describe('generateCopilotSetupSteps', () => { it('generates valid YAML workflow content', () => { const content = generateCopilotSetupSteps(); + expect(content).toContain('Generated by OpenSpec for GitHub Copilot coding agent support.'); expect(content).toContain('name: "Copilot Setup Steps"'); expect(content).toContain('copilot-setup-steps:'); expect(content).toContain('runs-on: ubuntu-latest'); @@ -50,6 +82,8 @@ describe('GitHub Copilot Cloud Agent', () => { describe('generateCopilotAgentFile', () => { it('generates agent markdown with frontmatter', () => { const content = generateCopilotAgentFile(); + expect(content).toMatch(/^---\n/); + expect(content).toContain('Generated by OpenSpec for GitHub Copilot coding agent support.'); expect(content).toContain('name: OpenSpec'); expect(content).toContain('description:'); expect(content).toContain('tools:'); @@ -138,5 +172,50 @@ describe('GitHub Copilot Cloud Agent', () => { 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); + 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, generateLegacyCopilotAgentFile()); + + 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' }); + }); }); }); From 4388e7ff8a45a281e84fbce9d056f89071ffbf61 Mon Sep 17 00:00:00 2001 From: NicoAvanzDev <35104310+NicoAvanzDev@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:24:36 +0200 Subject: [PATCH 6/8] fix: harden Copilot legacy file matching --- src/core/github-copilot/cloud-agent.ts | 62 +++++++---- test/core/github-copilot-cloud-agent.test.ts | 106 ++++++++++++++----- 2 files changed, 121 insertions(+), 47 deletions(-) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index 61990c3c96..6f7a8ce53c 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -70,9 +70,11 @@ jobs: * This tells the GitHub Copilot coding agent how to use the OpenSpec CLI. */ export function generateCopilotAgentFile(): string { - return generateCopilotAgentFileBody().replace( + return replaceRequired( + generateCopilotAgentFileBody(), '\n# OpenSpec Agent', - `\n\n\n# OpenSpec Agent` + `\n\n\n# OpenSpec Agent`, + 'agent heading' ); } @@ -152,28 +154,48 @@ When the user wants to propose a new change: } function generateLegacyCopilotAgentFileBody(): string { - return generateCopilotAgentFileBody() - .replace( - `tools: + let content = generateCopilotAgentFileBody(); + content = replaceRequired( + content, + `tools: - "execute" - "read" - "search" - "edit"`, - `tools: - - "terminal"` - ) - .replace( - '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`.' - ) - .replace( - '| `openspec status [--change ] [--json]` | Show artifact progress for a change |', - '| `openspec status [--json]` | Show artifact progress for active changes |' - ) - .replace( - '| `openspec instructions [artifact] [--change ] [--json]` | Get next-step instructions for a change |', - '| `openspec instructions [--json]` | Get next-step instructions for a change |' - ); + `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' + ); + return 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' + ); +} + +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); } /** diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index 41e1ece4b7..1e60e002c7 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -11,38 +11,87 @@ import { 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 { - return content + const withoutMarker = content .replace(/^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, '') .replace(/\n\n/, ''); - } - - function generateLegacyCopilotAgentFile(): string { - return removeManagedMarker(generateCopilotAgentFile()) - .replace( - `tools: - - "execute" - - "read" - - "search" - - "edit"`, - `tools: - - "terminal"` - ) - .replace( - '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`.' - ) - .replace( - '| `openspec status [--change ] [--json]` | Show artifact progress for a change |', - '| `openspec status [--json]` | Show artifact progress for active changes |' - ) - .replace( - '| `openspec instructions [artifact] [--change ] [--json]` | Get next-step instructions for a change |', - '| `openspec instructions [--json]` | Get next-step instructions for a change |' - ); + expect(withoutMarker).not.toBe(content); + expect(withoutMarker).not.toContain(MANAGED_MARKER); + return withoutMarker; } beforeEach(async () => { @@ -206,13 +255,16 @@ describe('GitHub Copilot Cloud Agent', () => { 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, generateLegacyCopilotAgentFile()); + 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' }); From 3ea34c6f67c655ec27dfe4cf84e1d356ab29823c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 4 Aug 2026 17:13:45 -0500 Subject: [PATCH 7/8] fix(copilot): harden cloud agent file management --- .changeset/add-copilot-cloud-agent-files.md | 5 ++++ src/core/github-copilot/cloud-agent.ts | 15 +++++++--- test/core/github-copilot-cloud-agent.test.ts | 31 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 .changeset/add-copilot-cloud-agent-files.md 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 index c1b5ef81e3..b37a3445d3 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -144,7 +144,6 @@ When the user wants to propose a new change: - \`openspec/\` — Root OpenSpec directory - \`openspec/changes/\` — Active changes with their artifacts - \`openspec/config.yaml\` — Project configuration -- \`openspec/explorations/\` — Exploration documents ## Best Practices @@ -216,12 +215,19 @@ When the user wants to propose a new change: '| `openspec status [--json]` | Show artifact progress for active changes |', 'legacy status command row' ); - return replaceRequired( + 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( @@ -261,9 +267,10 @@ function isManagedCopilotCloudFile( relPath: (typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], content: string ): boolean { + const normalizedContent = content.replace(/\r\n/g, '\n'); return ( - content === COPILOT_CLOUD_FILE_CONTENTS[relPath] || - COPILOT_CLOUD_LEGACY_FILE_CONTENTS[relPath].includes(content) + normalizedContent === COPILOT_CLOUD_FILE_CONTENTS[relPath] || + COPILOT_CLOUD_LEGACY_FILE_CONTENTS[relPath].includes(normalizedContent) ); } diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index 1ccbdd49e0..7081c00141 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -94,6 +94,10 @@ describe('GitHub Copilot Cloud Agent', () => { return withoutMarker; } + function withCrLf(content: string): string { + return content.replace(/\n/g, '\r\n'); + } + beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-cloud-agent-')); }); @@ -273,5 +277,32 @@ describe('GitHub Copilot Cloud Agent', () => { 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); + }); }); }); From 1427cb7fee6b40e60755cd263f8e9f5725e2552c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 4 Aug 2026 18:02:58 -0500 Subject: [PATCH 8/8] fix(copilot): harden cloud agent file handling --- src/core/github-copilot/cloud-agent.ts | 241 +++++++++++++--- src/core/init.ts | 15 +- test/core/github-copilot-cloud-agent.test.ts | 288 +++++++++++++++++-- test/core/init.test.ts | 39 ++- test/core/update.test.ts | 23 ++ 5 files changed, 522 insertions(+), 84 deletions(-) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index b37a3445d3..c46b4175e0 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -8,6 +8,7 @@ */ import path from 'path'; +import { promises as fs } from 'fs'; import { FileSystemUtils } from '../../utils/file-system.js'; const COPILOT_TOOL_ID = 'github-copilot'; @@ -70,15 +71,14 @@ jobs: * This tells the GitHub Copilot coding agent how to use the OpenSpec CLI. */ export function generateCopilotAgentFile(): string { - return replaceRequired( - generateCopilotAgentFileBody(), - '\n# OpenSpec Agent', - `\n\n\n# OpenSpec Agent`, - 'agent heading' - ); + return generateCopilotAgentFileBody(true); } -function generateCopilotAgentFileBody(): string { +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." @@ -89,9 +89,9 @@ tools: - "edit" --- -# OpenSpec Agent +${managedMarker}# OpenSpec Agent -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. Before using the \`openspec\` CLI, run \`openspec --version\`. If it is unavailable, install it with \`npm install -g @fission-ai/openspec\`. ## What is OpenSpec? @@ -110,7 +110,7 @@ OpenSpec is a structured change management system for codebases. It organizes wo | \`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 \` | Archive a completed change | +| \`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) @@ -154,8 +154,35 @@ When the user wants to propose a new change: `; } -function generateLegacyCopilotAgentFileBody(): string { +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 @@ -250,57 +277,176 @@ export const COPILOT_CLOUD_FILES = { agent: path.join('.github', 'agents', 'openspec.agent.md'), } as const; -const COPILOT_CLOUD_FILE_CONTENTS: Record<(typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], string> = { +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(), }; -const COPILOT_CLOUD_LEGACY_FILE_CONTENTS: Record<(typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], string[]> = { - [COPILOT_CLOUD_FILES.setupSteps]: [generateCopilotSetupStepsBody()], - [COPILOT_CLOUD_FILES.agent]: [ +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: (typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], + relPath: CopilotCloudFile, content: string ): boolean { - const normalizedContent = content.replace(/\r\n/g, '\n'); - return ( - normalizedContent === COPILOT_CLOUD_FILE_CONTENTS[relPath] || - COPILOT_CLOUD_LEGACY_FILE_CONTENTS[relPath].includes(normalizedContent) + 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}` ); } /** - * Write copilot cloud agent files to the project directory. - * Only writes if the files don't already exist (to avoid overwriting user customizations). + * 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, - options?: { force?: boolean } + projectPath: string ): Promise<{ setupStepsWritten: boolean; agentWritten: boolean }> { - const force = options?.force ?? false; - let setupStepsWritten = false; - let agentWritten = false; - - const setupStepsPath = path.join(projectPath, COPILOT_CLOUD_FILES.setupSteps); - const agentPath = path.join(projectPath, COPILOT_CLOUD_FILES.agent); + 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 + ); - // Write copilot-setup-steps.yml - if (force || !(await FileSystemUtils.fileExists(setupStepsPath))) { - await FileSystemUtils.writeFile(setupStepsPath, generateCopilotSetupSteps()); - setupStepsWritten = true; - } + await assertCreatableFilePath(setupStepsPath); + await assertCreatableFilePath(agentPath); + await assertMissingOrRegularFile(setupStepsPath); + await assertMissingOrRegularFile(agentPath); + await assertMissingOrRegularFile(alternateAgentPath); + const agentReconciliation = await classifyCopilotAgentReconciliation( + agentPath, + alternateAgentPath + ); - // Write openspec.agent.md - if (force || !(await FileSystemUtils.fileExists(agentPath))) { - await FileSystemUtils.writeFile(agentPath, generateCopilotAgentFile()); - agentWritten = true; + 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 }; @@ -314,17 +460,22 @@ export async function writeCopilotCloudFiles( */ 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 of Object.values(COPILOT_CLOUD_FILES)) { - const fullPath = path.join(projectPath, relPath); + for (const { relPath, fullPath } of managedPaths) { if (await FileSystemUtils.fileExists(fullPath)) { const content = await FileSystemUtils.readFile(fullPath); if (!isManagedCopilotCloudFile(relPath, content)) { continue; } - const fs = await import('fs'); - await fs.promises.unlink(fullPath); + await fs.unlink(fullPath); removed++; } } diff --git a/src/core/init.ts b/src/core/init.ts index 99eb89b14d..de451d0dea 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -60,7 +60,7 @@ import { shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js'; -import { includesGitHubCopilot, writeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; +import { writeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -770,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}`); @@ -784,16 +787,6 @@ export class InitCommand { } } - // Generate GitHub Copilot coding agent cloud files if github-copilot is selected - if (includesGitHubCopilot(tools.map((t) => t.value))) { - try { - await writeCopilotCloudFiles(projectPath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`Warning: failed to generate Copilot cloud agent files: ${message}`); - } - } - return { createdTools, refreshedTools, diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index 7081c00141..70891c2d6f 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -2,6 +2,7 @@ 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, @@ -98,6 +99,14 @@ describe('GitHub Copilot Cloud Agent', () => { 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-')); }); @@ -121,34 +130,57 @@ describe('GitHub Copilot Cloud Agent', () => { }); describe('generateCopilotSetupSteps', () => { - it('generates valid YAML workflow content', () => { + it('generates a structurally valid Copilot setup workflow', () => { const content = generateCopilotSetupSteps(); - expect(content).toContain('Generated by OpenSpec for GitHub Copilot coding agent support.'); - expect(content).toContain('name: "Copilot Setup Steps"'); - expect(content).toContain('copilot-setup-steps:'); - expect(content).toContain('runs-on: ubuntu-latest'); - expect(content).toContain('npm install -g @fission-ai/openspec'); - expect(content).toContain('openspec --version'); + 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 agent markdown with frontmatter', () => { + it('generates valid agent frontmatter and non-interactive guidance', () => { const content = generateCopilotAgentFile(); - expect(content).toMatch(/^---\n/); + 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('name: OpenSpec'); - expect(content).toContain('description:'); - expect(content).toContain('tools:'); - expect(content).toContain('execute'); - expect(content).toContain('read'); - expect(content).toContain('search'); - expect(content).toContain('edit'); 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'); }); @@ -170,7 +202,7 @@ describe('GitHub Copilot Cloud Agent', () => { await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.agent))).resolves.toBeTruthy(); }); - it('skips existing files by default', async () => { + 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 }); @@ -185,19 +217,159 @@ describe('GitHub Copilot Cloud Agent', () => { await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent'); }); - it('overwrites existing files when force is true', async () => { + 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, 'custom setup'); - await fs.writeFile(agentPath, 'custom agent'); + await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps())); + await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE); - const result = await writeCopilotCloudFiles(tempDir, { force: true }); + const result = await writeCopilotCloudFiles(tempDir); expect(result).toEqual({ setupStepsWritten: true, agentWritten: true }); - await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('copilot-setup-steps:'); - await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('# OpenSpec Agent'); + 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 }); + } }); }); @@ -304,5 +476,75 @@ describe('GitHub Copilot Cloud Agent', () => { 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 505993af6b..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'; @@ -875,17 +896,25 @@ describe('InitCommand', () => { expect(await fileExists(cmdFile)).toBe(true); }); - it('should warn when GitHub Copilot cloud files cannot be generated', async () => { + 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 warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); - await initCommand.execute(testDir); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: GitHub Copilot' + ); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('failed to generate Copilot cloud agent files') + 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 f1d4bc9a1d..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'; @@ -1218,6 +1219,28 @@ metadata: 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);