Skip to content
199 changes: 199 additions & 0 deletions src/core/github-copilot/cloud-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* 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:
- "execute"
- "read"
- "search"
- "edit"
---

# 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\`.

## 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 <item> [--json]\` | View a specific change or spec |
| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues |
| \`openspec status [--change <name>] [--json]\` | Show artifact progress for a change |
| \`openspec instructions [artifact] [--change <name>] [--json]\` | Get next-step instructions for a change |
| \`openspec templates [--json]\` | List available templates |
| \`openspec schemas [--json]\` | List available workflow schemas |
| \`openspec archive <change>\` | 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/<change-name>/\`
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<number> {
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
scanInstalledWorkflows as scanInstalledWorkflowsShared,
migrateIfNeeded as migrateIfNeededShared,
} from './migration.js';
import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles } from './github-copilot/cloud-agent.js';

const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
Expand Down Expand Up @@ -152,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])]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Still check for new tool directories and extra workflows
this.detectNewTools(resolvedProjectPath, configuredTools);
Expand Down Expand Up @@ -278,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);
Expand All @@ -296,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<void> {
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.
*/
Expand Down
124 changes: 124 additions & 0 deletions test/core/github-copilot-cloud-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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);
});

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('execute');
expect(content).toContain('# OpenSpec Agent');
expect(content).toContain('openspec list');
expect(content).toContain('openspec validate');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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' });
});
});
});
Loading