Skip to content
288 changes: 288 additions & 0 deletions src/core/github-copilot/cloud-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
/**
* 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';
const OPENSPEC_MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.';

/**
* Check if a tool list includes github-copilot.
*/
export function includesGitHubCopilot(toolIds: string[]): boolean {
return toolIds.includes(COPILOT_TOOL_ID);
}

/**
* Generate the copilot-setup-steps.yml workflow file content.
* This workflow pre-installs the OpenSpec CLI in the Copilot coding agent's
* ephemeral GitHub Actions environment.
*/
export function generateCopilotSetupSteps(): string {
return `# ${OPENSPEC_MANAGED_MARKER}

${generateCopilotSetupStepsBody()}`;
}

function generateCopilotSetupStepsBody(): string {
return `name: "Copilot Setup Steps"

# Runs automatically when changed (for validation) and can be triggered manually.
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml

jobs:
# The job MUST be called \`copilot-setup-steps\` for Copilot coding agent to pick it up.
copilot-setup-steps:
runs-on: ubuntu-latest
timeout-minutes: 10

permissions:
contents: read

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install OpenSpec CLI
run: npm install -g @fission-ai/openspec

- name: Verify OpenSpec CLI
run: openspec --version
`;
}

/**
* Generate the .github/agents/openspec.agent.md custom agent file content.
* This tells the GitHub Copilot coding agent how to use the OpenSpec CLI.
*/
export function generateCopilotAgentFile(): string {
return replaceRequired(
generateCopilotAgentFileBody(),
'\n# OpenSpec Agent',
`\n<!-- ${OPENSPEC_MANAGED_MARKER} -->\n\n# OpenSpec Agent`,
'agent heading'
);
}

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."
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
`;
}

function generateLegacyCopilotAgentFileBody(): string {
let content = generateCopilotAgentFileBody();
content = replaceRequired(
content,
`tools:
- "execute"
- "read"
- "search"
- "edit"`,
`tools:
- "terminal"`,
'legacy tool alias'
);
content = replaceRequired(
content,
'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.',
'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI which is pre-installed in the development environment via `copilot-setup-steps.yml`.',
'legacy CLI access sentence'
);
content = replaceRequired(
content,
'| `openspec status [--change <name>] [--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 <name>] [--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);
}

/**
* File paths (relative to project root) for the generated files.
*/
export const COPILOT_CLOUD_FILES = {
setupSteps: path.join('.github', 'workflows', 'copilot-setup-steps.yml'),
agent: path.join('.github', 'agents', 'openspec.agent.md'),
} as const;

const COPILOT_CLOUD_FILE_CONTENTS: Record<(typeof COPILOT_CLOUD_FILES)[keyof typeof COPILOT_CLOUD_FILES], string> = {
[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]: [
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).
*
* @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 content = await FileSystemUtils.readFile(fullPath);
if (!isManagedCopilotCloudFile(relPath, content)) {
continue;
}

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 @@ -611,6 +612,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
22 changes: 21 additions & 1 deletion 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 @@ -114,8 +115,10 @@ export class UpdateCommand {

// 5. Find configured tools
const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);
const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];

if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);
console.log(chalk.yellow('No configured tools found.'));
console.log(chalk.dim('Run "openspec init" to set up tools.'));
return;
Expand Down Expand Up @@ -152,6 +155,7 @@ export class UpdateCommand {
if (!this.force && toolsToUpdateSet.size === 0) {
// All tools are up to date
this.displayUpToDateMessage(toolStatuses);
await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);

// Still check for new tool directories and extra workflows
this.detectNewTools(resolvedProjectPath, configuredTools);
Expand Down Expand Up @@ -277,7 +281,7 @@ export class UpdateCommand {
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
}

const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];
await this.syncCopilotCloudFiles(resolvedProjectPath, configuredAndNewTools);

// 13. Detect new tool directories not currently configured
this.detectNewTools(resolvedProjectPath, configuredAndNewTools);
Expand All @@ -296,6 +300,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
Loading