From 0d600d9d418a0d83abed6a5c2bb31c291c5be648 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 5 Aug 2026 12:44:54 -0500 Subject: [PATCH 1/5] feat(copilot): make cloud coding-agent files opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting the `github-copilot` tool auto-generated a GitHub Actions workflow (.github/workflows/copilot-setup-steps.yml) plus an agent file. Writing into a user's CI on init/update is invasive, benefits only the narrow set of Copilot *cloud* coding-agent users, and couples us to GitHub's externally-owned custom-agent format. Cloud files are now opt-in: - `openspec init` prompts before generating them (default No) and records the choice in openspec/config.yaml (`githubCopilot.cloudAgent`). - `--copilot-cloud` / `--no-copilot-cloud` decide non-interactively. - `openspec update` never prompts; it only refreshes files for projects that opted in, or that already have generated cloud files (so existing setups keep working — the migration path). The pre-existing content-matching guarantees are unchanged and now proven by regression tests: a user-customized cloud file is never overwritten or deleted. Opt-in state is persisted via the YAML document model so the user's hand-authored config comments and formatting survive untouched. Co-Authored-By: Claude Opus 4.8 --- .changeset/copilot-cloud-opt-in.md | 5 + src/cli/index.ts | 5 +- src/core/completions/command-registry.ts | 8 ++ src/core/github-copilot/cloud-agent.ts | 83 ++++++++++++++ src/core/init.ts | 94 ++++++++++++++- src/core/project-config.ts | 28 +++++ src/core/update.ts | 11 +- test/core/github-copilot-cloud-agent.test.ts | 114 +++++++++++++++++++ test/core/init.test.ts | 63 +++++++++- test/core/update.test.ts | 46 +++++++- 10 files changed, 444 insertions(+), 13 deletions(-) create mode 100644 .changeset/copilot-cloud-opt-in.md diff --git a/.changeset/copilot-cloud-opt-in.md b/.changeset/copilot-cloud-opt-in.md new file mode 100644 index 000000000..6e9b70361 --- /dev/null +++ b/.changeset/copilot-cloud-opt-in.md @@ -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. diff --git a/src/cli/index.ts b/src/cli/index.ts index a20a1b489..4a963749e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -161,7 +161,9 @@ program .option('--force', 'Auto-cleanup legacy files without prompting') .option('--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); @@ -188,6 +190,7 @@ program force: options?.force, profile: options?.profile, animation: options?.animation, + copilotCloud: options?.copilotCloud, }); await initCommand.execute(targetPath); } catch (error) { diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 33db57e87..2d139b304 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -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', + }, ], }, { diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index c46b4175e..273ec3abb 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -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.'; @@ -482,3 +484,84 @@ export async function removeCopilotCloudFiles(projectPath: string): Promise { + 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 { + 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 { + 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()); +} diff --git a/src/core/init.ts b/src/core/init.ts index 93e428e2c..006c77d45 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -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'); @@ -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 = { @@ -136,6 +147,7 @@ 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; @@ -143,6 +155,7 @@ export class InitCommand { this.interactiveOption = options.interactive; this.profileOverride = options.profile; this.animation = options.animation ?? true; + this.copilotCloudOption = options.copilotCloud; } async execute(targetPath: string): Promise { @@ -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). @@ -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) { @@ -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; @@ -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; @@ -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); } diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 8469a2f21..922e31505 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -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. */ @@ -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).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) { diff --git a/src/core/update.ts b/src/core/update.ts index 998550121..904520f49 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -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'); @@ -486,7 +486,14 @@ export class UpdateCommand { private async syncCopilotCloudFiles(projectPath: string, configuredTools: string[]): Promise { 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; } diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index 70891c2d6..9cd86a7ef 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -10,6 +10,10 @@ import { COPILOT_CLOUD_FILES, removeCopilotCloudFiles, writeCopilotCloudFiles, + readCopilotCloudOptIn, + hasExistingManagedCloudFiles, + isCopilotCloudEnabled, + persistCopilotCloudOptIn, } from '../../src/core/github-copilot/cloud-agent.js'; const MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; @@ -547,4 +551,114 @@ describe('GitHub Copilot Cloud Agent', () => { } }); }); + + describe('cloud opt-in', () => { + const CONFIG_WITH_COMMENTS = `schema: spec-driven + +# Project context (optional) +context: | + Tech stack: TypeScript +`; + + async function writeConfig(content: string): Promise { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, content); + return configPath; + } + + describe('readCopilotCloudOptIn', () => { + it('returns undefined when there is no config', () => { + expect(readCopilotCloudOptIn(tempDir)).toBeUndefined(); + }); + + it('reads an explicit opt-in and opt-out', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: false\n`); + expect(readCopilotCloudOptIn(tempDir)).toBe(false); + }); + + it('treats a non-boolean value as undecided', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: "yes"\n`); + expect(readCopilotCloudOptIn(tempDir)).toBeUndefined(); + }); + }); + + describe('persistCopilotCloudOptIn', () => { + it('writes the nested key while preserving existing comments and content', async () => { + const configPath = await writeConfig(CONFIG_WITH_COMMENTS); + + await persistCopilotCloudOptIn(tempDir, true); + + const written = await fs.readFile(configPath, 'utf8'); + expect(written).toContain('# Project context (optional)'); + expect(written).toContain('Tech stack: TypeScript'); + expect(parse(written)).toMatchObject({ + schema: 'spec-driven', + githubCopilot: { cloudAgent: true }, + }); + // Round-trips through the reader. + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + }); + + it('flips an existing decision in place', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`); + await persistCopilotCloudOptIn(tempDir, false); + expect(readCopilotCloudOptIn(tempDir)).toBe(false); + }); + + it('is a no-op when no config file exists', async () => { + await persistCopilotCloudOptIn(tempDir, true); + await expect( + fs.stat(path.join(tempDir, 'openspec', 'config.yaml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + describe('hasExistingManagedCloudFiles', () => { + it('is false on a clean project', async () => { + await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(false); + }); + + it('is true when a managed file exists, false for a purely customized one', async () => { + await writeCopilotCloudFiles(tempDir); + await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(true); + + // Replace both managed files with customized content: no longer "managed". + await fs.writeFile( + path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps), + 'name: my own workflow\n' + ); + await fs.writeFile( + path.join(tempDir, COPILOT_CLOUD_FILES.agent), + 'my own agent instructions\n' + ); + await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(false); + }); + }); + + describe('isCopilotCloudEnabled', () => { + it('honors an explicit opt-out even when managed files exist', async () => { + await writeCopilotCloudFiles(tempDir); + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: false\n`); + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(false); + }); + + it('honors an explicit opt-in with no files yet', async () => { + await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`); + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(true); + }); + + it('falls back to existing managed files when undecided (migration)', async () => { + await writeCopilotCloudFiles(tempDir); + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(true); + }); + + it('is false when undecided and no managed files exist', async () => { + await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(false); + }); + }); + }); }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 9002902cd..09f077e00 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -183,7 +183,11 @@ describe('InitCommand', () => { process.platform === 'win32' ? 'junction' : 'dir' ); - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); await expect(initCommand.execute(testDir)).rejects.toThrow( 'OpenSpec setup failed for: GitHub Copilot' ); @@ -1047,7 +1051,11 @@ describe('InitCommand', () => { await fs.mkdir(path.dirname(agentsPath), { recursive: true }); await fs.writeFile(agentsPath, 'blocks the generated agent directory'); - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); await expect(initCommand.execute(testDir)).rejects.toThrow( 'OpenSpec setup failed for: GitHub Copilot' ); @@ -1057,6 +1065,57 @@ describe('InitCommand', () => { 'OpenSpec Setup Incomplete' ); }); + + it('does not write cloud files by default (opt-in) but still installs local Copilot files', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + // Local Copilot command files are unaffected by the cloud opt-in. + expect( + await fileExists(path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md')) + ).toBe(true); + // Cloud files are NOT written without an explicit opt-in. + 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' }); + // An undecided run leaves config untouched (no githubCopilot key). + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).not.toContain('githubCopilot'); + }); + + it('writes cloud files and persists the opt-in when --copilot-cloud is passed', async () => { + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); + await initCommand.execute(testDir); + + await expect( + fs.readFile(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'), 'utf8') + ).resolves.toContain('copilot-setup-steps:'); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('githubCopilot:'); + expect(config).toContain('cloudAgent: true'); + }); + + it('persists an explicit opt-out and writes no cloud files with --no-copilot-cloud', async () => { + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: false, + }); + await initCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 78eecd029..775403b08 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -104,7 +104,11 @@ describe('UpdateCommand', () => { }); it('should remove generated Copilot cloud files when no tools are configured', async () => { - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: 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 }); @@ -1708,7 +1712,7 @@ metadata: }); it('should create GitHub Copilot cloud files when github-copilot is up to date', async () => { - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); await initCommand.execute(testDir); const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); @@ -1723,7 +1727,7 @@ metadata: }); it('should refresh managed legacy Copilot files and preserve custom files during force update', async () => { - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); await initCommand.execute(testDir); const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); @@ -1744,10 +1748,44 @@ metadata: await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customAgent); }); - it('should warn when GitHub Copilot cloud files cannot be synchronized', async () => { + it('should not create cloud files on update when Copilot is configured but not opted in', async () => { + // Seed a configured github-copilot WITHOUT opting into cloud files. + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + 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' }); + }); + + it('should refresh pre-existing managed cloud files even without a config opt-in (migration)', async () => { + // A project created before the opt-in existed: managed files are present + // but config carries no githubCopilot key. Update must keep them current. 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 legacySetupSteps = generateCopilotSetupSteps().replace( + /^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, + '' + ); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, legacySetupSteps); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(generateCopilotSetupSteps()); + }); + + it('should warn when GitHub Copilot cloud files cannot be synchronized', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); + await initCommand.execute(testDir); + const agentsPath = path.join(testDir, '.github', 'agents'); await fs.rm(agentsPath, { recursive: true, force: true }); await fs.writeFile(agentsPath, 'blocks the generated agent directory'); From c4d433226f9a61dbe4a71b2bb77bade0393746db Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 5 Aug 2026 13:04:14 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(copilot):=20polish=20the=20cloud=20opt?= =?UTF-8?q?-in=20=E2=80=94=20safety,=20UX,=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hardening driven by a five-agent review swarm over the opt-in. Correctness: - persistCopilotCloudOptIn no longer throws on a scalar/`null` config file (reproduced crash); it starts a fresh map while preserving comment-only and empty files. - Explicit opt-out (`--no-copilot-cloud` / `cloudAgent: false`) now removes OpenSpec-managed cloud files on both init and update, instead of orphaning them. Customized files are still never touched. - `--copilot-cloud` / `--no-copilot-cloud` warns when github-copilot isn't among the selected tools, instead of silently no-opping. UX / discoverability: - init prints whether cloud files were written or, when skipped for want of a signal, how to enable them (`--copilot-cloud`). - When the user opts in but already has their own copilot-setup-steps.yml or agent file, init/update say it was left untouched and that the OpenSpec install step must be added by hand — the direct answer to "will this affect my existing Copilot cloud agent?". - Clearer interactive prompt (names both files; distinguishes the GitHub-hosted cloud agent from Copilot in the editor); a dim, interactive-only, decision- gated hint on `openspec update`; tightened flag help text. Docs (the feature was undocumented): new "GitHub Copilot cloud coding agent" section in supported-tools.md; init flags in cli.md; the githubCopilot.cloudAgent key in customization.md. Tests: interactive prompt (accept/decline), opt-out removal + customized-file preservation, config.yml variant, scalar-config regression, collision reporting, flag-ignored warning, re-init honoring persisted opt-in, and the config parse/warn branches. 2763 tests pass; the only failures are pre-existing and unrelated (completion mocks, adapters loader, one config-profile PATH case, one experimental-alias case), verified identical on clean main. Co-Authored-By: Claude Opus 4.8 --- .changeset/copilot-cloud-opt-in.md | 6 +- docs/cli.md | 2 + docs/customization.md | 6 ++ docs/supported-tools.md | 20 ++++- src/cli/index.ts | 4 +- src/core/github-copilot/cloud-agent.ts | 34 +++++++- src/core/init.ts | 90 +++++++++++++++++--- src/core/update.ts | 31 ++++++- test/core/github-copilot-cloud-agent.test.ts | 44 ++++++++++ test/core/init.test.ts | 69 +++++++++++++++ test/core/project-config.test.ts | 50 +++++++++++ test/core/update.test.ts | 29 ++++++- 12 files changed, 365 insertions(+), 20 deletions(-) diff --git a/.changeset/copilot-cloud-opt-in.md b/.changeset/copilot-cloud-opt-in.md index 6e9b70361..7a542c69f 100644 --- a/.changeset/copilot-cloud-opt-in.md +++ b/.changeset/copilot-cloud-opt-in.md @@ -2,4 +2,8 @@ "@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. +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). +- Opting out (`--no-copilot-cloud` or `cloudAgent: false`) removes OpenSpec-managed cloud files; a user-customized file is always preserved, never overwritten or deleted. +- `init` and `update` now report whether cloud files were written, skipped, or left untouched — and if you already have your own `copilot-setup-steps.yml`, they say it was preserved and that you need to add the OpenSpec install step by hand. diff --git a/docs/cli.md b/docs/cli.md index 0cb7666c1..c76ffb9ad 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,6 +102,8 @@ openspec init [path] [options] | `--force` | Auto-cleanup legacy files without prompting | | `--profile ` | Override global profile for this init run (`core` or `custom`) | | `--no-animation` | Show a static welcome screen instead of the animated one | +| `--copilot-cloud` | Set up GitHub Copilot [cloud coding-agent files](supported-tools.md#github-copilot-cloud-coding-agent) without prompting | +| `--no-copilot-cloud` | Skip GitHub Copilot cloud coding-agent files without prompting | `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). diff --git a/docs/customization.md b/docs/customization.md index 0321e481a..b1143b927 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -18,6 +18,7 @@ The `openspec/config.yaml` file is the easiest way to customize OpenSpec for you - **Inject project context** - AI sees your tech stack, conventions, etc. - **Add per-artifact rules** - Custom rules for specific artifacts - **Add per-operation guidance** - Advisory preferences for apply and archive work +- **Remember integration choices** - e.g. the [GitHub Copilot cloud coding agent](supported-tools.md#github-copilot-cloud-coding-agent) opt-in ### Quick Setup @@ -52,6 +53,11 @@ operations: archive: guidance: - Keep the completion summary concise + +# Set by `openspec init` when you choose (or decline) the GitHub Copilot +# cloud coding agent; controls whether `init`/`update` generate its files. +githubCopilot: + cloudAgent: false ``` ### How It Works diff --git a/docs/supported-tools.md b/docs/supported-tools.md index f7b186174..756a80e87 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -102,7 +102,7 @@ to read the hint. | ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/.md` | | Shared `.agents` skills (`agents`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | -\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. +\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. Selecting `github-copilot` can also set up the GitHub-hosted **cloud coding agent** — see [GitHub Copilot cloud coding agent](#github-copilot-cloud-coding-agent) below. \*\*\* Hermes loads skills from `~/.hermes/skills/` by default. To use project-local OpenSpec skills, add the project `.hermes/skills/` directory to `skills.external_dirs` in `~/.hermes/config.yaml`; Hermes then exposes skills with user-facing slash invocations such as `/openspec-propose`. @@ -114,6 +114,24 @@ repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves existing global MiniMax Code skills untouched so one project's delivery setting cannot remove skills used by another project. +### GitHub Copilot cloud coding agent + +GitHub's [Copilot coding agent](https://docs.github.com/en/copilot/using-github-copilot/coding-agent) runs on GitHub in a GitHub Actions environment — separate from Copilot in your editor. OpenSpec can set it up to use the OpenSpec CLI by generating two files: + +- `.github/workflows/copilot-setup-steps.yml` — installs `@fission-ai/openspec` in the agent's environment +- `.github/agents/openspec.agent.md` — tells the agent how to drive OpenSpec + +Because this writes a GitHub Actions workflow into your repository, it is **opt-in**: + +| How | Behavior | +|-----|----------| +| `openspec init` (interactive) | Asks whether to set up cloud files. Default is **No**. | +| `openspec init --copilot-cloud` | Sets them up without prompting (for scripts/CI). | +| `openspec init --no-copilot-cloud` | Skips them without prompting, and removes any previously generated ones. | +| `openspec update` | Never prompts. Refreshes the files only if you opted in (or the project already has them). If you opted out, it removes OpenSpec-managed cloud files. | + +Your choice is saved in `openspec/config.yaml` as `githubCopilot.cloudAgent: true|false`, so non-interactive updates honor it. OpenSpec only ever writes or removes files whose content it generated — if you customize `copilot-setup-steps.yml` or `openspec.agent.md`, or already have your own, it is left untouched (and `init`/`update` tell you so). + ### When to pick the shared `.agents` target `agents` is the vendor-neutral option: it writes skills to `.agents/skills/`, the diff --git a/src/cli/index.ts b/src/cli/index.ts index 4a963749e..619f958ec 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -161,8 +161,8 @@ program .option('--force', 'Auto-cleanup legacy files without prompting') .option('--profile ', 'Override global config profile (core or custom)') .option('--no-animation', 'Show a static welcome screen instead of the animated one') - .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') + .option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting') + .option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting') .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean; copilotCloud?: boolean }) => { try { // Validate that the path is a valid directory diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index 273ec3abb..6eb939b27 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -9,7 +9,7 @@ import path from 'path'; import { promises as fs } from 'fs'; -import { parseDocument } from 'yaml'; +import { Document, parseDocument, isCollection } from 'yaml'; import { FileSystemUtils } from '../../utils/file-system.js'; import { readProjectConfig, resolveConfigFilePath } from '../project-config.js'; @@ -561,7 +561,37 @@ export async function persistCopilotCloudOptIn( return; } const existing = await FileSystemUtils.readFile(configPath); - const doc = parseDocument(existing); + const parsed = parseDocument(existing); + // A config whose top-level node is a scalar (e.g. the file contains literally + // `null` or a bare string) has no map to set a key on, and `setIn` throws on + // it. Such a file is already invalid (readProjectConfig rejects it), so start + // fresh rather than crash. An empty or comment-only file parses to null + // contents, which setIn fills in while keeping the comments — so only a + // genuine scalar is discarded. + const doc: Document = + parsed.contents !== null && !isCollection(parsed.contents) ? new Document() : parsed; doc.setIn([COPILOT_CONFIG_KEY, COPILOT_CLOUD_AGENT_KEY], value); await FileSystemUtils.writeFile(configPath, doc.toString()); } + +/** + * Return the managed cloud-file paths (relative to the project root) that + * currently hold user-owned, non-managed content — i.e. files OpenSpec will + * deliberately leave untouched. Used to tell an opted-in user that we preserved + * their existing file rather than silently doing nothing, which is the honest + * answer to "will this affect my existing Copilot cloud setup?". + */ +export async function findUnmanagedCloudFiles(projectPath: string): Promise { + const collisions: string[] = []; + 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)) { + collisions.push(relPath); + } + } + return collisions; +} diff --git a/src/core/init.ts b/src/core/init.ts index df73634db..412c434b3 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -69,6 +69,9 @@ import { readCopilotCloudOptIn, hasExistingManagedCloudFiles, persistCopilotCloudOptIn, + removeCopilotCloudFiles, + findUnmanagedCloudFiles, + COPILOT_CLOUD_FILES, } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); @@ -281,8 +284,32 @@ export class InitCommand { } } + // An explicit opt-out means "no cloud files here": clean up any that a + // previous run (or an older OpenSpec) generated. Only OpenSpec-managed + // files are removed — a user-customized file is preserved. + if (copilotDecision.optedOut) { + try { + await removeCopilotCloudFiles(projectPath); + } catch { + // Non-fatal: nothing was written this run, so there is nothing to undo. + } + } + + // If the user opted in but already had their own (non-managed) cloud file, + // we left it untouched. Surface that so the write never looks like it + // silently did nothing — and tell them what to do about it. + const copilotSucceeded = [...results.createdTools, ...results.refreshedTools].some( + (tool) => tool.value === 'github-copilot' + ); + const copilotCollisions = + copilotDecision.write && copilotSucceeded ? await findUnmanagedCloudFiles(projectPath) : []; + // Display success message - this.displaySuccessMessage(projectPath, validatedTools, results, configStatus); + this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, { + write: copilotDecision.write, + skippedUndecided: copilotDecision.skippedUndecided, + collisions: copilotCollisions, + }); if (results.failedTools.length > 0) { throw new Error( `OpenSpec setup failed for: ${results.failedTools.map((tool) => tool.name).join(', ')}` @@ -323,44 +350,61 @@ export class InitCommand { * 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). + * back to config (undefined = leave config untouched); `optedOut` — the user + * explicitly declined, so any already-generated managed files should be + * removed; `skippedUndecided` — selected but no signal and couldn't ask, so + * the caller can hint that the opt-in exists. */ private async resolveCopilotCloudDecision( projectPath: string, tools: ValidatedInitTool[] - ): Promise<{ write: boolean; persist?: boolean }> { + ): Promise<{ write: boolean; persist?: boolean; optedOut: boolean; skippedUndecided: boolean }> { const copilotSelected = tools.some((tool) => tool.value === 'github-copilot'); if (!copilotSelected) { - return { write: false }; + // A flag that can't apply is a likely mistake — say so rather than no-op. + if (this.copilotCloudOption !== undefined) { + console.log( + chalk.yellow( + '--copilot-cloud/--no-copilot-cloud was ignored because the github-copilot tool was not selected.' + ) + ); + } + return { write: false, optedOut: false, skippedUndecided: false }; } if (this.copilotCloudOption !== undefined) { - return { write: this.copilotCloudOption, persist: this.copilotCloudOption }; + return { + write: this.copilotCloudOption, + persist: this.copilotCloudOption, + optedOut: !this.copilotCloudOption, + skippedUndecided: false, + }; } const persistedOptIn = readCopilotCloudOptIn(projectPath); if (typeof persistedOptIn === 'boolean') { - return { write: persistedOptIn }; + return { write: persistedOptIn, optedOut: !persistedOptIn, skippedUndecided: false }; } if (await hasExistingManagedCloudFiles(projectPath)) { - return { write: true }; + return { write: true, optedOut: false, skippedUndecided: false }; } 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.', + 'Set up GitHub Copilot cloud coding-agent files? This is for the GitHub-hosted ' + + 'Copilot coding agent (github.com), not Copilot in your editor. It writes two files: ' + + '.github/workflows/copilot-setup-steps.yml and .github/agents/openspec.agent.md.', default: false, }); - return { write: answer, persist: answer }; + return { write: answer, persist: answer, optedOut: !answer, skippedUndecided: false }; } // 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 }; + return { write: false, optedOut: false, skippedUndecided: true }; } private resolveProfileOverride(): Profile | undefined { @@ -970,7 +1014,8 @@ export class InitCommand { removedCommandCount: number; removedSkillCount: number; }, - configStatus: 'created' | 'exists' | 'skipped' + configStatus: 'created' | 'exists' | 'skipped', + copilot: { write: boolean; skippedUndecided: boolean; collisions: string[] } ): void { console.log(); console.log( @@ -1077,6 +1122,27 @@ export class InitCommand { console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); } + // GitHub Copilot cloud files are opt-in — say plainly whether they were + // written, and (when skipped for want of a signal) how to turn them on. + const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot'); + if (copilotSucceeded && copilot.write) { + console.log( + `GitHub Copilot cloud files: ${COPILOT_CLOUD_FILES.setupSteps}, ${COPILOT_CLOUD_FILES.agent}` + ); + if (copilot.collisions.length > 0) { + console.log( + chalk.dim( + `Left your existing ${copilot.collisions.join(' and ')} untouched — add the OpenSpec ` + + `install step by hand so the Copilot cloud agent can run openspec.` + ) + ); + } + } else if (copilotSucceeded && copilot.skippedUndecided) { + console.log( + chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'.") + ); + } + // Show manual setup notes for tools that need extra configuration for (const tool of successfulTools) { const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote; diff --git a/src/core/update.ts b/src/core/update.ts index 904520f49..3ac02feb4 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -71,7 +71,7 @@ import { shouldRemoveSkillsForTool, } from './command-surface.js'; import { writeSharedSkillTarget } from './shared-skill-target.js'; -import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled } from './github-copilot/cloud-agent.js'; +import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -493,6 +493,35 @@ export class UpdateCommand { // step, never a silent side effect of running update. if (await isCopilotCloudEnabled(projectPath)) { await writeCopilotCloudFiles(projectPath); + const collisions = await findUnmanagedCloudFiles(projectPath); + if (collisions.length > 0) { + console.log( + chalk.dim( + `Left your existing ${collisions.join(' and ')} untouched — add the OpenSpec ` + + `install step by hand so the Copilot cloud agent can run openspec.` + ) + ); + } + return; + } + + // Explicit opt-out (githubCopilot.cloudAgent: false) means "not here": + // remove any managed files a prior opt-in left behind (customized files + // are preserved). If the user simply never decided, stay quiet unless + // we're at an interactive terminal, where a one-line hint aids discovery. + if (readCopilotCloudOptIn(projectPath) === false) { + const removed = await removeCopilotCloudFiles(projectPath); + if (removed > 0) { + console.log( + chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (opted out of cloud files)`) + ); + } + } else if (isInteractive()) { + console.log( + chalk.dim( + "GitHub Copilot cloud coding-agent files are available (opt-in). Enable with 'openspec init --copilot-cloud'." + ) + ); } return; } diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index 9cd86a7ef..19b437b75 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -14,6 +14,7 @@ import { hasExistingManagedCloudFiles, isCopilotCloudEnabled, persistCopilotCloudOptIn, + findUnmanagedCloudFiles, } from '../../src/core/github-copilot/cloud-agent.js'; const MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; @@ -615,6 +616,49 @@ context: | fs.stat(path.join(tempDir, 'openspec', 'config.yaml')) ).rejects.toMatchObject({ code: 'ENOENT' }); }); + + it('persists into and reads from config.yml when only .yml exists', async () => { + const ymlPath = path.join(tempDir, 'openspec', 'config.yml'); + await fs.mkdir(path.dirname(ymlPath), { recursive: true }); + await fs.writeFile(ymlPath, `${CONFIG_WITH_COMMENTS}`); + + await persistCopilotCloudOptIn(tempDir, true); + + // No sibling .yaml was created; the .yml file was edited in place. + await expect( + fs.stat(path.join(tempDir, 'openspec', 'config.yaml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + const written = await fs.readFile(ymlPath, 'utf8'); + expect(written).toContain('# Project context (optional)'); + expect(written).toContain('cloudAgent: true'); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + }); + + it('does not throw on a scalar-content config and writes a valid map', async () => { + // A degenerate config whose top-level node is a bare scalar used to + // throw "Expected a YAML collection as document contents". + await writeConfig('null\n'); + await expect(persistCopilotCloudOptIn(tempDir, false)).resolves.toBeUndefined(); + expect(readCopilotCloudOptIn(tempDir)).toBe(false); + }); + }); + + describe('findUnmanagedCloudFiles', () => { + it('is empty on a clean project and after a managed write', async () => { + await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([]); + await writeCopilotCloudFiles(tempDir); + await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([]); + }); + + it('reports a user-owned (non-managed) file that would be left untouched', async () => { + const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'name: my own build workflow\n'); + + await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([ + COPILOT_CLOUD_FILES.setupSteps, + ]); + }); }); describe('hasExistingManagedCloudFiles', () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 044a30ed8..67bbf519b 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1385,6 +1385,75 @@ describe('InitCommand - profile and detection features', () => { expect(githubCopilot?.preSelected).toBe(true); }); + it('interactive init: confirming the cloud prompt writes files and persists the opt-in', async () => { + searchableMultiSelectMock.mockResolvedValue(['github-copilot']); + confirmMock.mockImplementation(({ message }: { message: string }) => + Promise.resolve(String(message).includes('Copilot cloud coding-agent')) + ); + + const initCommand = new InitCommand({}); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + await initCommand.execute(testDir); + + expect( + await fileExists(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).toBe(true); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: true'); + expect(confirmMock).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('copilot-setup-steps.yml') }) + ); + }); + + it('interactive init: declining the cloud prompt writes no cloud files but keeps local ones', async () => { + searchableMultiSelectMock.mockResolvedValue(['github-copilot']); + confirmMock.mockResolvedValue(false); + + const initCommand = new InitCommand({}); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + await initCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + // Local Copilot prompt files are unaffected by the cloud decision. + expect( + await fileExists(path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md')) + ).toBe(true); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); + + it('re-init with --no-copilot-cloud removes previously generated managed cloud files', async () => { + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + expect(await fileExists(setupStepsPath)).toBe(true); + + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: false }).execute(testDir); + + expect(await fileExists(setupStepsPath)).toBe(false); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); + + it('re-init without a flag honors the persisted opt-in', async () => { + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + await fs.rm(setupStepsPath, { force: true }); + + // No flag this run: the persisted cloudAgent: true must drive the write. + await new InitCommand({ tools: 'github-copilot', force: true }).execute(testDir); + + expect(await fileExists(setupStepsPath)).toBe(true); + }); + + it('warns when --copilot-cloud is passed but github-copilot is not selected', async () => { + await new InitCommand({ tools: 'claude', force: true, copilotCloud: true }).execute(testDir); + + const out = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(out).toContain('was ignored because the github-copilot tool was not selected'); + }); + it('should respect custom profile from global config', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 285caca0a..2adbdf9ad 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -182,6 +182,56 @@ operations: ); }); + it('should parse githubCopilot.cloudAgent', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: + cloudAgent: true +` + ); + + expect(readProjectConfig(tempDir)?.githubCopilot?.cloudAgent).toBe(true); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should warn on a non-boolean cloudAgent and keep the rest of the config', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: + cloudAgent: "yes" +` + ); + + const config = readProjectConfig(tempDir); + expect(config?.schema).toBe('spec-driven'); + expect(config?.githubCopilot).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'githubCopilot.cloudAgent' field") + ); + }); + + it('should warn on a non-object githubCopilot field', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: true +` + ); + + expect(readProjectConfig(tempDir)?.schema).toBe('spec-driven'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'githubCopilot' field") + ); + }); + it('should ignore malformed operation entries independently', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 775403b08..3a40b7e97 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -4,7 +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 { generateCopilotSetupSteps, persistCopilotCloudOptIn } from '../../src/core/github-copilot/cloud-agent.js'; import path from 'path'; import fs from 'fs/promises'; import os from 'os'; @@ -1782,6 +1782,33 @@ metadata: await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(generateCopilotSetupSteps()); }); + it('should remove managed cloud files on update when the user has opted out', async () => { + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const agentPath = path.join(testDir, '.github', 'agents', 'openspec.agent.md'); + expect(await fs.stat(setupStepsPath)).toBeTruthy(); + + await persistCopilotCloudOptIn(testDir, false); // explicit opt-out + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('should preserve a customized cloud file on update even when opted out', async () => { + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await fs.writeFile(setupStepsPath, 'name: my own workflow\n'); + + await persistCopilotCloudOptIn(testDir, false); // explicit opt-out + + await new UpdateCommand({ force: true }).execute(testDir); + + // A user-customized file is never removed, even on opt-out. + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('name: my own workflow\n'); + }); + it('should warn when GitHub Copilot cloud files cannot be synchronized', async () => { const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); await initCommand.execute(testDir); From b4113d5fef71fc505928908e28a397b83ffdc834 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 5 Aug 2026 13:23:33 -0500 Subject: [PATCH 3/5] fix(copilot): make init cloud-file output honest; harden config guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final hardening pass (adversarial review of the opt-in polish). - init's success line listed both cloud-file paths from the *decision* to write, not from what was written — so it claimed files that a write skipped (user already owns them) or that the alternate-agent path removed. It now lists only OpenSpec-managed files that actually exist after the write (listManagedCloudFiles), keeps the "left untouched" caveat for user-owned files, and reports opt-out removals in the normal output block. - persistCopilotCloudOptIn's non-map guard used isCollection, which is also true for sequences, so a YAML list at the config root still made setIn throw. Gate on isMap so scalars AND sequences fall back to a fresh document; empty/comment-only files still round-trip with comments intact. - Fixed a misleading catch comment on the opt-out removal path. Tests: success-line accuracy over a user-owned file, sequence-root config regression, and listManagedCloudFiles coverage. 318 tests pass across the touched suites; build + lint clean. Co-Authored-By: Claude Opus 4.8 --- src/core/github-copilot/cloud-agent.ts | 37 ++++++++++++---- src/core/init.ts | 46 ++++++++++++++------ test/core/github-copilot-cloud-agent.test.ts | 29 ++++++++++++ test/core/init.test.ts | 18 ++++++++ 4 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index 6eb939b27..97b0c52ba 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -9,7 +9,7 @@ import path from 'path'; import { promises as fs } from 'fs'; -import { Document, parseDocument, isCollection } from 'yaml'; +import { Document, parseDocument, isMap } from 'yaml'; import { FileSystemUtils } from '../../utils/file-system.js'; import { readProjectConfig, resolveConfigFilePath } from '../project-config.js'; @@ -562,14 +562,14 @@ export async function persistCopilotCloudOptIn( } const existing = await FileSystemUtils.readFile(configPath); const parsed = parseDocument(existing); - // A config whose top-level node is a scalar (e.g. the file contains literally - // `null` or a bare string) has no map to set a key on, and `setIn` throws on - // it. Such a file is already invalid (readProjectConfig rejects it), so start - // fresh rather than crash. An empty or comment-only file parses to null - // contents, which setIn fills in while keeping the comments — so only a - // genuine scalar is discarded. + // `setIn(['githubCopilot', ...])` needs a top-level map. A config whose root + // is anything else — a scalar (`null`, a bare string) or even a sequence — + // has no map to set a key on and makes setIn throw. Such a file is already + // invalid (readProjectConfig rejects it), so start fresh rather than crash. + // An empty or comment-only file parses to null contents, which setIn fills in + // while keeping the comments — so only a non-map root is discarded. const doc: Document = - parsed.contents !== null && !isCollection(parsed.contents) ? new Document() : parsed; + parsed.contents === null || isMap(parsed.contents) ? parsed : new Document(); doc.setIn([COPILOT_CONFIG_KEY, COPILOT_CLOUD_AGENT_KEY], value); await FileSystemUtils.writeFile(configPath, doc.toString()); } @@ -595,3 +595,24 @@ export async function findUnmanagedCloudFiles(projectPath: string): Promise { + const present: string[] = []; + 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)) { + present.push(relPath); + } + } + return present; +} diff --git a/src/core/init.ts b/src/core/init.ts index 412c434b3..76c2a9697 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -71,7 +71,7 @@ import { persistCopilotCloudOptIn, removeCopilotCloudFiles, findUnmanagedCloudFiles, - COPILOT_CLOUD_FILES, + listManagedCloudFiles, } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); @@ -287,28 +287,34 @@ export class InitCommand { // An explicit opt-out means "no cloud files here": clean up any that a // previous run (or an older OpenSpec) generated. Only OpenSpec-managed // files are removed — a user-customized file is preserved. + let copilotRemoved = 0; if (copilotDecision.optedOut) { try { - await removeCopilotCloudFiles(projectPath); + copilotRemoved = await removeCopilotCloudFiles(projectPath); } catch { - // Non-fatal: nothing was written this run, so there is nothing to undo. + // Non-fatal: removal targets files from a prior run; a failure here + // just leaves them for the next `openspec update` to clean up. } } - // If the user opted in but already had their own (non-managed) cloud file, - // we left it untouched. Surface that so the write never looks like it - // silently did nothing — and tell them what to do about it. + // Report the cloud outcome from what is actually on disk after the write, + // not from the decision alone: writing over a user-owned file is a no-op, + // and the alternate-agent path can remove a managed file — so list only + // managed files that exist, and separately flag any left-untouched ones. const copilotSucceeded = [...results.createdTools, ...results.refreshedTools].some( (tool) => tool.value === 'github-copilot' ); - const copilotCollisions = - copilotDecision.write && copilotSucceeded ? await findUnmanagedCloudFiles(projectPath) : []; + const wroteCloud = copilotDecision.write && copilotSucceeded; + const copilotPresent = wroteCloud ? await listManagedCloudFiles(projectPath) : []; + const copilotCollisions = wroteCloud ? await findUnmanagedCloudFiles(projectPath) : []; // Display success message this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, { write: copilotDecision.write, skippedUndecided: copilotDecision.skippedUndecided, + present: copilotPresent, collisions: copilotCollisions, + removed: copilotRemoved, }); if (results.failedTools.length > 0) { throw new Error( @@ -1015,7 +1021,13 @@ export class InitCommand { removedSkillCount: number; }, configStatus: 'created' | 'exists' | 'skipped', - copilot: { write: boolean; skippedUndecided: boolean; collisions: string[] } + copilot: { + write: boolean; + skippedUndecided: boolean; + present: string[]; + collisions: string[]; + removed: number; + } ): void { console.log(); console.log( @@ -1122,13 +1134,15 @@ export class InitCommand { console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); } - // GitHub Copilot cloud files are opt-in — say plainly whether they were - // written, and (when skipped for want of a signal) how to turn them on. + // GitHub Copilot cloud files are opt-in — report what is actually on disk: + // list the managed files that now exist (never files we didn't write), flag + // any user-owned file we left untouched, note an opt-out cleanup, or (when + // skipped for want of a signal) say how to turn them on. const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot'); if (copilotSucceeded && copilot.write) { - console.log( - `GitHub Copilot cloud files: ${COPILOT_CLOUD_FILES.setupSteps}, ${COPILOT_CLOUD_FILES.agent}` - ); + if (copilot.present.length > 0) { + console.log(`GitHub Copilot cloud files: ${copilot.present.join(', ')}`); + } if (copilot.collisions.length > 0) { console.log( chalk.dim( @@ -1137,6 +1151,10 @@ export class InitCommand { ) ); } + } else if (copilotSucceeded && copilot.removed > 0) { + console.log( + chalk.dim(`Removed: ${copilot.removed} Copilot cloud agent file(s) (opted out of cloud files)`) + ); } else if (copilotSucceeded && copilot.skippedUndecided) { console.log( chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'.") diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index 19b437b75..e1d083db6 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -15,6 +15,7 @@ import { isCopilotCloudEnabled, persistCopilotCloudOptIn, findUnmanagedCloudFiles, + listManagedCloudFiles, } from '../../src/core/github-copilot/cloud-agent.js'; const MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; @@ -641,6 +642,34 @@ context: | await expect(persistCopilotCloudOptIn(tempDir, false)).resolves.toBeUndefined(); expect(readCopilotCloudOptIn(tempDir)).toBe(false); }); + + it('does not throw on a sequence-root config and writes a valid map', async () => { + // A YAML list at the root is also not a map: setIn would throw, so it + // must be replaced with a fresh document rather than crash. + await writeConfig('- a\n- b\n'); + await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined(); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + }); + }); + + describe('listManagedCloudFiles', () => { + it('is empty on a clean project and lists managed files after a write', async () => { + await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([]); + await writeCopilotCloudFiles(tempDir); + await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([ + COPILOT_CLOUD_FILES.setupSteps, + COPILOT_CLOUD_FILES.agent, + ]); + }); + + it('excludes a user-owned (non-managed) file', async () => { + await writeCopilotCloudFiles(tempDir); + await fs.writeFile( + path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps), + 'name: my own build workflow\n' + ); + await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([COPILOT_CLOUD_FILES.agent]); + }); }); describe('findUnmanagedCloudFiles', () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 67bbf519b..55e611f76 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1454,6 +1454,24 @@ describe('InitCommand - profile and detection features', () => { expect(out).toContain('was ignored because the github-copilot tool was not selected'); }); + it('opting in over a user-owned cloud file never claims that file was written', async () => { + const setupRel = path.join('.github', 'workflows', 'copilot-setup-steps.yml'); + const agentRel = path.join('.github', 'agents', 'openspec.agent.md'); + const setupStepsPath = path.join(testDir, setupRel); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'name: my own workflow\n'); + + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + + const out = vi.mocked(console.log).mock.calls.flat().join('\n'); + // Only the agent file was actually written; the workflow was left untouched. + expect(out).toContain(`GitHub Copilot cloud files: ${agentRel}`); + expect(out).not.toContain(`cloud files: ${setupRel}`); + expect(out).toContain(`Left your existing ${setupRel} untouched`); + // And the user's own file is preserved verbatim. + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('name: my own workflow\n'); + }); + it('should respect custom profile from global config', async () => { saveGlobalConfig({ featureFlags: {}, From fe543647f1fe586a52c65e7fdc7cdd854851e036 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 5 Aug 2026 13:37:48 -0500 Subject: [PATCH 4/5] fix(copilot): replace a non-map githubCopilot node before setIn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses alfred review on #1517. The prior guard only fixed a non-map config *root*; a valid top-level map whose `githubCopilot` value is itself a scalar/null/sequence (`githubCopilot: false`, `null`, or a list) still made `setIn(['githubCopilot','cloudAgent'], ...)` throw, which init swallowed — so the explicit opt-in/out was never saved. Now the intermediate node is replaced with an empty map before descending, keeping the rest of the config and its comments intact. Regression covers all three reproduced cases (false/null/sequence). Full suite: 2770 pass; only the pre-existing unrelated failures remain. Co-Authored-By: Claude Opus 4.8 --- src/core/github-copilot/cloud-agent.ts | 9 ++++++++- test/core/github-copilot-cloud-agent.test.ts | 21 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index 97b0c52ba..7903e55dc 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -9,7 +9,7 @@ import path from 'path'; import { promises as fs } from 'fs'; -import { Document, parseDocument, isMap } from 'yaml'; +import { Document, YAMLMap, parseDocument, isMap } from 'yaml'; import { FileSystemUtils } from '../../utils/file-system.js'; import { readProjectConfig, resolveConfigFilePath } from '../project-config.js'; @@ -570,6 +570,13 @@ export async function persistCopilotCloudOptIn( // while keeping the comments — so only a non-map root is discarded. const doc: Document = parsed.contents === null || isMap(parsed.contents) ? parsed : new Document(); + // The root is a map now, but the `githubCopilot` node itself may be a stray + // scalar/sequence/null (e.g. `githubCopilot: false`) — descending into that + // with setIn also throws. Replace any non-map node with an empty map first. + const section = doc.getIn([COPILOT_CONFIG_KEY], true); + if (section !== undefined && !isMap(section)) { + doc.setIn([COPILOT_CONFIG_KEY], new YAMLMap()); + } doc.setIn([COPILOT_CONFIG_KEY, COPILOT_CLOUD_AGENT_KEY], value); await FileSystemUtils.writeFile(configPath, doc.toString()); } diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index e1d083db6..ef8ad44f8 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -650,6 +650,27 @@ context: | await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined(); expect(readCopilotCloudOptIn(tempDir)).toBe(true); }); + + it('does not throw when the githubCopilot node itself is not a map', async () => { + // Root is a valid map, but `githubCopilot` holds a scalar/null/sequence: + // descending into it with setIn used to throw. Each must be replaced + // with a map, keeping the rest of the config (and its comments) intact. + for (const bad of [ + 'githubCopilot: false', + 'githubCopilot: null', + 'githubCopilot:\n - a\n - b', + ]) { + await writeConfig(`schema: spec-driven\n# keep me\n${bad}\n`); + await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined(); + expect(readCopilotCloudOptIn(tempDir)).toBe(true); + const written = await fs.readFile( + path.join(tempDir, 'openspec', 'config.yaml'), + 'utf8' + ); + expect(written).toContain('# keep me'); + expect(written).toContain('schema: spec-driven'); + } + }); }); describe('listManagedCloudFiles', () => { From 99aef0de782097b019f978accbd94fb92d95312f Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 5 Aug 2026 13:40:36 -0500 Subject: [PATCH 5/5] fix(copilot): never throw persisting into an unparseable config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deeper pass on persistCopilotCloudOptIn (the function alfred flagged), driven by an exhaustive input-shape check. Two malformed inputs still threw at toString(): a multi-document YAML stream and a tab-indented (syntactically invalid) file. Such a file can't be edited without corrupting it, so persist now detects parse errors and leaves it untouched (no throw, no clobber) — it is already invalid, so readProjectConfig ignores it regardless. With this the function is throw-free across every shape exercised: empty, comment-only, scalar/sequence root, a non-map githubCopilot value, anchors, CRLF, BOM, and the two malformed cases (now skipped byte-identical). Regression added for the multi-document case. Touched suites: 314 pass. Co-Authored-By: Claude Opus 4.8 --- src/core/github-copilot/cloud-agent.ts | 7 +++++++ test/core/github-copilot-cloud-agent.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index 7903e55dc..551037c91 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -562,6 +562,13 @@ export async function persistCopilotCloudOptIn( } const existing = await FileSystemUtils.readFile(configPath); const parsed = parseDocument(existing); + // A file YAML can't parse cleanly — a multi-document stream, a tab-indented + // syntax error — can't be edited without corrupting it, and toString() would + // throw. Leave it untouched rather than clobber or crash; such a file is + // already invalid, so readProjectConfig ignores it anyway. + if (parsed.errors.length > 0) { + return; + } // `setIn(['githubCopilot', ...])` needs a top-level map. A config whose root // is anything else — a scalar (`null`, a bare string) or even a sequence — // has no map to set a key on and makes setIn throw. Such a file is already diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index ef8ad44f8..863bff827 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -651,6 +651,17 @@ context: | expect(readCopilotCloudOptIn(tempDir)).toBe(true); }); + it('leaves an unparseable config untouched instead of throwing', async () => { + // A multi-document stream can't be edited without corrupting it; persist + // must skip it (no throw, no clobber) rather than crash. + const malformed = '---\na: 1\n---\nb: 2\n'; + const configPath = await writeConfig(malformed); + + await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined(); + + expect(await fs.readFile(configPath, 'utf8')).toBe(malformed); + }); + it('does not throw when the githubCopilot node itself is not a map', async () => { // Root is a valid map, but `githubCopilot` holds a scalar/null/sequence: // descending into it with setIn used to throw. Each must be replaced