Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
148 changes: 148 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, YAMLMap, 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,149 @@ 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);
// 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
// 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();
// 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());
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