Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/copilot-cloud-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": minor
---

Make GitHub Copilot cloud coding-agent files opt-in. Selecting the `github-copilot` tool no longer silently writes a GitHub Actions workflow into `.github/`; `openspec init` now asks first (default No) and remembers the choice in `openspec/config.yaml` (`githubCopilot.cloudAgent`). Use `--copilot-cloud` / `--no-copilot-cloud` to decide non-interactively. `openspec update` never prompts — it only refreshes cloud files for projects that opted in (or that already have generated cloud files, so existing setups keep working). User-customized cloud files continue to be preserved and are never overwritten or deleted.
5 changes: 4 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ program
.option('--force', 'Auto-cleanup legacy files without prompting')
.option('--profile <profile>', 'Override global config profile (core or custom)')
.option('--no-animation', 'Show a static welcome screen instead of the animated one')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean }) => {
.option('--copilot-cloud', 'Generate GitHub Copilot cloud coding-agent files (opt-in; default: prompt)')
.option('--no-copilot-cloud', 'Skip generating GitHub Copilot cloud coding-agent files')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean; copilotCloud?: boolean }) => {
try {
// Validate that the path is a valid directory
const resolvedPath = path.resolve(targetPath);
Expand All @@ -188,6 +190,7 @@ program
force: options?.force,
profile: options?.profile,
animation: options?.animation,
copilotCloud: options?.copilotCloud,
});
await initCommand.execute(targetPath);
} catch (error) {
Expand Down
8 changes: 8 additions & 0 deletions src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
name: 'no-animation',
description: 'Show a static welcome screen instead of the animated one',
},
{
name: 'copilot-cloud',
description: 'Generate GitHub Copilot cloud coding-agent files (opt-in; default: prompt)',
},
{
name: 'no-copilot-cloud',
description: 'Skip generating GitHub Copilot cloud coding-agent files',
},
],
},
{
Expand Down
83 changes: 83 additions & 0 deletions src/core/github-copilot/cloud-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@

import path from 'path';
import { promises as fs } from 'fs';
import { parseDocument } from 'yaml';
import { FileSystemUtils } from '../../utils/file-system.js';
import { readProjectConfig, resolveConfigFilePath } from '../project-config.js';

const COPILOT_TOOL_ID = 'github-copilot';
const OPENSPEC_MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.';
Expand Down Expand Up @@ -482,3 +484,84 @@ export async function removeCopilotCloudFiles(projectPath: string): Promise<numb

return removed;
}

// ─────────────────────────────────────────────────────────────────────────────
// Opt-in
//
// Generating a GitHub Actions workflow into a user's `.github/` is invasive and
// ties us to Copilot's externally-owned coding-agent format, so cloud files are
// opt-in rather than an automatic side effect of selecting the Copilot tool.
// The decision is persisted in openspec/config.yaml so non-interactive
// `openspec update` (CI, agents) honors it without ever prompting.
// ─────────────────────────────────────────────────────────────────────────────

const COPILOT_CONFIG_KEY = 'githubCopilot';
const COPILOT_CLOUD_AGENT_KEY = 'cloudAgent';

/**
* Read the persisted opt-in for Copilot cloud-file generation.
*
* Tri-state: `true` (opted in), `false` (explicitly opted out), or `undefined`
* (never decided). A malformed value is treated as undecided rather than an
* error, matching how {@link readProjectConfig} degrades on bad fields.
*/
export function readCopilotCloudOptIn(projectPath: string): boolean | undefined {
const value = readProjectConfig(projectPath)?.githubCopilot?.cloudAgent;
return typeof value === 'boolean' ? value : undefined;
}

/**
* True when a managed Copilot cloud file (the current generation or a
* recognized legacy one) already exists. Projects created before the opt-in
* prompt existed are treated as implicitly opted in, so `openspec update`
* keeps their files current instead of silently abandoning them.
*/
export async function hasExistingManagedCloudFiles(projectPath: string): Promise<boolean> {
for (const relPath of Object.values(COPILOT_CLOUD_FILES)) {
const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath);
if (!(await FileSystemUtils.fileExists(fullPath))) {
continue;
}
const content = await FileSystemUtils.readFile(fullPath);
if (isManagedCopilotCloudFile(relPath, content)) {
return true;
}
}
return false;
}

/**
* Effective decision on whether to generate/refresh Copilot cloud files.
* An explicit opt-in or opt-out always wins; when undecided, fall back to
* whether managed files already exist (the migration path above).
*/
export async function isCopilotCloudEnabled(projectPath: string): Promise<boolean> {
const optIn = readCopilotCloudOptIn(projectPath);
if (typeof optIn === 'boolean') {
return optIn;
}
return hasExistingManagedCloudFiles(projectPath);
}

/**
* Persist the Copilot cloud opt-in into openspec/config.yaml.
*
* Uses the YAML document model rather than a re-serialize so the user's
* existing comments, ordering, and formatting survive untouched — the config
* file is hand-authored and heavily commented, so a lossy round-trip would be
* its own source of toil. No-op when no config file exists yet (init creates it
* before this is called); the caller treats persistence failures as non-fatal.
*/
export async function persistCopilotCloudOptIn(
projectPath: string,
value: boolean
): Promise<void> {
const configPath = resolveConfigFilePath(projectPath);
if (!configPath) {
return;
}
const existing = await FileSystemUtils.readFile(configPath);
const doc = parseDocument(existing);
doc.setIn([COPILOT_CONFIG_KEY, COPILOT_CLOUD_AGENT_KEY], value);
await FileSystemUtils.writeFile(configPath, doc.toString());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
94 changes: 90 additions & 4 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ import {
shouldReconcileCommandFilesForTool,
shouldRemoveSkillsForTool,
} from './command-surface.js';
import { writeCopilotCloudFiles } from './github-copilot/cloud-agent.js';
import {
writeCopilotCloudFiles,
readCopilotCloudOptIn,
hasExistingManagedCloudFiles,
persistCopilotCloudOptIn,
} from './github-copilot/cloud-agent.js';

const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
Expand Down Expand Up @@ -106,6 +111,12 @@ type InitCommandOptions = {
profile?: string;
/** Commander's --no-animation flag: false disables the welcome animation. */
animation?: boolean;
/**
* Explicit opt-in/out for GitHub Copilot cloud coding-agent files.
* `--copilot-cloud` sets true, `--no-copilot-cloud` sets false; undefined
* leaves the decision to config, migration, or an interactive prompt.
*/
copilotCloud?: boolean;
};

type ValidatedInitTool = {
Expand Down Expand Up @@ -136,13 +147,15 @@ export class InitCommand {
private readonly interactiveOption?: boolean;
private readonly profileOverride?: string;
private readonly animation: boolean;
private readonly copilotCloudOption?: boolean;

constructor(options: InitCommandOptions = {}) {
this.toolsArg = options.tools;
this.force = options.force ?? false;
this.interactiveOption = options.interactive;
this.profileOverride = options.profile;
this.animation = options.animation ?? true;
this.copilotCloudOption = options.copilotCloud;
}

async execute(targetPath: string): Promise<void> {
Expand Down Expand Up @@ -231,11 +244,22 @@ export class InitCommand {
if (kept) console.log(chalk.dim(kept));
}

// Decide whether to generate GitHub Copilot cloud files. This is opt-in
// (see cloud-agent.ts): selecting the Copilot tool no longer silently
// writes a GitHub Actions workflow into the user's .github/. The decision
// is made before generation so the write can be gated, and persisted after
// config.yaml exists so future non-interactive updates honor it.
const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools);

// Create directory structure and config
await this.createDirectoryStructure(openspecPath, extendMode);

// Generate skills and commands for each tool
const results = await this.generateSkillsAndCommands(projectPath, validatedTools);
const results = await this.generateSkillsAndCommands(
projectPath,
validatedTools,
copilotDecision.write
);

// Legacy cleanup was deferred to avoid interfering with skill/command generation;
// now that outputs are written, finalize the cleanup (e.g. remove stale files).
Expand All @@ -246,6 +270,17 @@ export class InitCommand {
// Create config.yaml if needed
const configStatus = await this.createConfig(openspecPath, extendMode);

// Persist an explicit Copilot cloud decision so `openspec update` (which
// never prompts) honors it. Best-effort: a config-write failure must not
// fail an otherwise-successful init.
if (copilotDecision.persist !== undefined) {
try {
await persistCopilotCloudOptIn(projectPath, copilotDecision.persist);
} catch {
// Non-fatal: the files (if any) were still written correctly.
}
}

// Display success message
this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);
if (results.failedTools.length > 0) {
Expand Down Expand Up @@ -278,6 +313,56 @@ export class InitCommand {
return isInteractive({ interactive: this.interactiveOption });
}

/**
* Decide whether to generate GitHub Copilot cloud files, and whether to
* persist that decision. Precedence:
* 1. `--copilot-cloud` / `--no-copilot-cloud` flag (explicit this run)
* 2. persisted opt-in in config.yaml
* 3. managed files already present (migration for pre-opt-in projects)
* 4. interactive confirm (default No)
* 5. non-interactive with no signal: skip, and don't persist a default
*
* @returns `write` — generate the files this run; `persist` — value to write
* back to config (undefined = leave config untouched).
*/
private async resolveCopilotCloudDecision(
projectPath: string,
tools: ValidatedInitTool[]
): Promise<{ write: boolean; persist?: boolean }> {
const copilotSelected = tools.some((tool) => tool.value === 'github-copilot');
if (!copilotSelected) {
return { write: false };
}

if (this.copilotCloudOption !== undefined) {
return { write: this.copilotCloudOption, persist: this.copilotCloudOption };
}

const persistedOptIn = readCopilotCloudOptIn(projectPath);
if (typeof persistedOptIn === 'boolean') {
return { write: persistedOptIn };
}

if (await hasExistingManagedCloudFiles(projectPath)) {
return { write: true };
}

if (this.canPromptInteractively()) {
const { confirm } = await import('@inquirer/prompts');
const answer = await confirm({
message:
'Set up GitHub Copilot cloud coding-agent files? This writes a GitHub Actions ' +
'workflow (.github/workflows/copilot-setup-steps.yml) and an agent file.',
default: false,
});
return { write: answer, persist: answer };
}

// Non-interactive with no explicit signal: don't write, and leave the
// decision unpersisted so a later interactive run can still prompt.
return { write: false };
}

private resolveProfileOverride(): Profile | undefined {
if (this.profileOverride === undefined) {
return undefined;
Expand Down Expand Up @@ -714,7 +799,8 @@ export class InitCommand {
*/
private async generateSkillsAndCommands(
projectPath: string,
tools: ValidatedInitTool[]
tools: ValidatedInitTool[],
writeCopilotCloud: boolean
): Promise<{
createdTools: typeof tools;
refreshedTools: typeof tools;
Expand Down Expand Up @@ -801,7 +887,7 @@ export class InitCommand {
if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
}
if (tool.value === 'github-copilot') {
if (tool.value === 'github-copilot' && writeCopilotCloud) {
await writeCopilotCloudFiles(projectPath);
}

Expand Down
28 changes: 28 additions & 0 deletions src/core/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ export const ProjectConfigSchema = z.object({
.string()
.optional()
.describe('Store id used as the OpenSpec root when no local planning shape exists'),

// Optional: GitHub Copilot integration preferences. `cloudAgent` is the
// opt-in for generating the Copilot cloud coding-agent files (a GitHub
// Actions workflow + agent file); absent means "not yet decided".
githubCopilot: z
.object({
cloudAgent: z.boolean().optional(),
})
.optional()
.describe('GitHub Copilot integration preferences'),
});

/** Normalized in-memory shape of a referenced store declaration. */
Expand Down Expand Up @@ -366,6 +376,24 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null {
}
}

// Parse githubCopilot preferences (only cloudAgent is recognized today).
if (raw.githubCopilot !== undefined) {
if (
typeof raw.githubCopilot === 'object' &&
raw.githubCopilot !== null &&
!Array.isArray(raw.githubCopilot)
) {
const cloudAgent = (raw.githubCopilot as Record<string, unknown>).cloudAgent;
if (typeof cloudAgent === 'boolean') {
config.githubCopilot = { cloudAgent };
} else if (cloudAgent !== undefined) {
console.warn(`Invalid 'githubCopilot.cloudAgent' field in config (must be a boolean)`);
}
} else {
console.warn(`Invalid 'githubCopilot' field in config (must be an object)`);
}
}

// Return partial config even if some fields failed
return Object.keys(config).length > 0 ? (config as ProjectConfig) : null;
} catch (error) {
Expand Down
11 changes: 9 additions & 2 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import {
shouldRemoveSkillsForTool,
} from './command-surface.js';
import { writeSharedSkillTarget } from './shared-skill-target.js';
import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles } from './github-copilot/cloud-agent.js';
import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled } from './github-copilot/cloud-agent.js';

const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
Expand Down Expand Up @@ -486,7 +486,14 @@ export class UpdateCommand {
private async syncCopilotCloudFiles(projectPath: string, configuredTools: string[]): Promise<void> {
try {
if (includesGitHubCopilot(configuredTools)) {
await writeCopilotCloudFiles(projectPath);
// Cloud files are opt-in (see cloud-agent.ts). `update` never prompts,
// so it only refreshes files the user has already opted into (via
// `openspec init` or a `githubCopilot.cloudAgent: true` config), or that
// a pre-opt-in project already has. Opting in is a deliberate init/config
// step, never a silent side effect of running update.
if (await isCopilotCloudEnabled(projectPath)) {
await writeCopilotCloudFiles(projectPath);
}
return;
}

Expand Down
Loading
Loading