Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
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
9 changes: 9 additions & 0 deletions .changeset/copilot-cloud-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Describe the non-interactive default.

openspec init does not always prompt. In non-interactive mode without either flag, it skips cloud files and does not persist a choice. State this behavior here to prevent incorrect automation assumptions.

Based on PR objectives: “Non-interactive initialization without a flag skips cloud files without persisting a choice.”

🧰 Tools
🪛 LanguageTool

[uncategorized] ~5-~5: The official name of this software platform is spelled with a capital “H”.
Context: ...y writes a GitHub Actions workflow into .github/; openspec init now asks first (defa...

(GITHUB)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/copilot-cloud-opt-in.md at line 5, Update the changeset
description to state that non-interactive openspec init without --copilot-cloud
or --no-copilot-cloud skips GitHub Copilot cloud-agent files and does not
persist a configuration choice.


- `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.
2 changes: 2 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ openspec init [path] [options]
| `--force` | Auto-cleanup legacy files without prompting |
| `--profile <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 |
Comment on lines +105 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document that the cloud flags require selecting github-copilot.

The runtime ignores --copilot-cloud and --no-copilot-cloud when github-copilot is absent from the selected tools. The documentation must state this condition.

  • docs/cli.md#L105-L106: explain that users must select github-copilot, such as with --tools github-copilot.
  • docs/supported-tools.md#L128-L130: add the same condition to the behavior table.
📍 Affects 2 files
  • docs/cli.md#L105-L106 (this comment)
  • docs/supported-tools.md#L128-L130
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/cli.md` around lines 105 - 106, Update the --copilot-cloud and
--no-copilot-cloud entries in docs/cli.md lines 105-106 to state that they only
apply when github-copilot is selected, such as with --tools github-copilot. Add
the same condition to the corresponding behavior table entries in
docs/supported-tools.md lines 128-130.


`--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`).

Expand Down
6 changes: 6 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment on lines +56 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clarify that cloudAgent: false is an explicit opt-out.

This manual configuration example persists a decision. It prevents later prompting and causes updates to remove managed cloud-agent files. Tell users to omit the optional block when no decision is intended, or label this example as an explicit opt-out.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/customization.md` around lines 56 - 60, Update the
githubCopilot.cloudAgent configuration example in the customization
documentation to clearly label false as an explicit opt-out, and state that
users should omit the optional block when they have made no decision.

```

### How It Works
Expand Down
20 changes: 19 additions & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ to read the hint.
| ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/<id>.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`.

Expand All @@ -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. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refer specifically to existing OpenSpec-managed files.

“The project already has them” can include customized or user-owned files. Replace it with “existing OpenSpec-managed files” so this sentence matches the protection guarantee in lines 132-133.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/supported-tools.md` at line 131, Update the `openspec update`
documentation row to replace “the project already has them” with wording that
specifically refers to “existing OpenSpec-managed files,” preserving the
surrounding refresh and removal behavior.


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
Expand Down
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', '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
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
134 changes: 134 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 { Document, parseDocument, isMap } 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,135 @@ 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 parsed = parseDocument(existing);
// `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 || isMap(parsed.contents) ? parsed : new Document();
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.
}

/**
* 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<string[]> {
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;
}

/**
* Return the managed cloud-file paths (relative to the project root) that
* currently exist and hold OpenSpec-generated content. Callers report this
* rather than the intended paths, so output never claims a file that a write
* skipped (user already owns it) or that reconciliation removed.
*/
export async function listManagedCloudFiles(projectPath: string): Promise<string[]> {
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;
}
Loading
Loading