/**
 * GitHub Copilot Cloud Agent Support
 *
 * Generates copilot-setup-steps.yml and .github/agents/openspec.agent.md
 * when the github-copilot tool is selected during init/update.
 * These files enable the GitHub Copilot coding agent (cloud) to use the
 * OpenSpec CLI in its ephemeral dev environment.
 */
import path from 'path';
import { 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.';
/**
 * Check if a tool list includes github-copilot.
 */
export function includesGitHubCopilot(toolIds) {
    return toolIds.includes(COPILOT_TOOL_ID);
}
/**
 * Generate the copilot-setup-steps.yml workflow file content.
 * This workflow pre-installs the OpenSpec CLI in the Copilot coding agent's
 * ephemeral GitHub Actions environment.
 */
export function generateCopilotSetupSteps() {
    return `# ${OPENSPEC_MANAGED_MARKER}

${generateCopilotSetupStepsBody()}`;
}
function generateCopilotSetupStepsBody() {
    return `name: "Copilot Setup Steps"

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

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

    permissions:
      contents: read

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

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

      - name: Verify OpenSpec CLI
        run: openspec --version
`;
}
/**
 * Generate the .github/agents/openspec.agent.md custom agent file content.
 * This tells the GitHub Copilot coding agent how to use the OpenSpec CLI.
 */
export function generateCopilotAgentFile() {
    return generateCopilotAgentFileBody(true);
}
function generateCopilotAgentFileBody(includeManagedMarker = false) {
    const managedMarker = includeManagedMarker
        ? `<!-- ${OPENSPEC_MANAGED_MARKER} -->\n\n`
        : '';
    return `---
name: OpenSpec
description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work."
tools:
  - "execute"
  - "read"
  - "search"
  - "edit"
---

${managedMarker}# OpenSpec Agent

You are a specialized agent for managing OpenSpec workflows. Before using the \`openspec\` CLI, run \`openspec --version\`. If it is unavailable, install it with \`npm install -g @fission-ai/openspec\`.

## What is OpenSpec?

OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation.

## Available Commands

### Agent-Compatible CLI Commands (prefer \`--json\` for structured output)

| Command | Purpose |
|---------|---------|
| \`openspec list [--json]\` | List all changes and specs |
| \`openspec show <item> [--json]\` | View a specific change or spec |
| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues |
| \`openspec status [--change <name>] [--json]\` | Show artifact progress for a change |
| \`openspec instructions [artifact] [--change <name>] [--json]\` | Get next-step instructions for a change |
| \`openspec templates [--json]\` | List available templates |
| \`openspec schemas [--json]\` | List available workflow schemas |
| \`openspec archive <change> --json [--yes]\` | Archive a completed change; use \`--yes\` only after confirming all tasks are complete |

### Interactive CLI Commands (use when prompted by the user)

| Command | Purpose |
|---------|---------|
| \`openspec init\` | Initialize OpenSpec in the project |
| \`openspec update\` | Update OpenSpec configuration and artifacts |
| \`openspec view\` | Interactive dashboard |
| \`openspec config\` | View or modify settings |

## Workflow

When asked to work with OpenSpec, follow this pattern:

1. **Find the change**: Run \`openspec list --json\` to see active changes.
2. **Check progress**: Run \`openspec status --change <name> --json\` for the selected change.
3. **Follow instructions**: Run \`openspec instructions [artifact] --change <name> --json\` for the next artifact.
4. **Validate before completing**: Run \`openspec validate <name> --json\`.

## Creating New Changes

When the user wants to propose a new change:

1. Run \`openspec new change <name>\`.
2. Run \`openspec status --change <name> --json\` to see the artifact sequence.
3. Use \`openspec instructions [artifact] --change <name> --json\` before creating each artifact.
4. Run \`openspec validate <name> --json\` when the artifacts are complete.

## Key Directories

- \`openspec/\` — Root OpenSpec directory
- \`openspec/changes/\` — Active changes with their artifacts
- \`openspec/config.yaml\` — Project configuration

## Best Practices

- Always use \`--json\` flag when you need to parse output programmatically
- Run \`openspec validate\` after creating or modifying artifacts
- Check \`openspec status\` before starting work to understand the current state
- When archiving, ensure all tasks are completed and validated first
`;
}
function generatePreviousCopilotAgentFileBody(includeManagedMarker = false) {
    let content = generateCopilotAgentFileBody();
    content = replaceRequired(content, 'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.', 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.', 'previous CLI access sentence');
    content = replaceRequired(content, '| `openspec archive <change> --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |', '| `openspec archive <change>` | Archive a completed change |', 'previous archive command row');
    if (!includeManagedMarker) {
        return content;
    }
    return replaceRequired(content, '\n# OpenSpec Agent', `\n<!-- ${OPENSPEC_MANAGED_MARKER} -->\n\n# OpenSpec Agent`, 'previous agent heading');
}
function generateLegacyCopilotAgentFileBody() {
    let content = generatePreviousCopilotAgentFileBody();
    content = replaceRequired(content, `## Workflow

When asked to work with OpenSpec, follow this pattern:

1. **Find the change**: Run \`openspec list --json\` to see active changes.
2. **Check progress**: Run \`openspec status --change <name> --json\` for the selected change.
3. **Follow instructions**: Run \`openspec instructions [artifact] --change <name> --json\` for the next artifact.
4. **Validate before completing**: Run \`openspec validate <name> --json\`.

## Creating New Changes

When the user wants to propose a new change:

1. Run \`openspec new change <name>\`.
2. Run \`openspec status --change <name> --json\` to see the artifact sequence.
3. Use \`openspec instructions [artifact] --change <name> --json\` before creating each artifact.
4. Run \`openspec validate <name> --json\` when the artifacts are complete.`, `## Workflow

When asked to work with OpenSpec, follow this pattern:

1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress.
2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps.
3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct.

## Creating New Changes

When the user wants to propose a new change:

1. Create the change directory under \`openspec/changes/<change-name>/\`
2. Generate the required planning artifacts based on the project's configured workflow schema
3. Run \`openspec validate --json\` to verify the artifacts are well-formed`, 'legacy workflow guidance');
    content = replaceRequired(content, `tools:
  - "execute"
  - "read"
  - "search"
  - "edit"`, `tools:
  - "terminal"`, 'legacy tool alias');
    content = replaceRequired(content, 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.', 'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI which is pre-installed in the development environment via `copilot-setup-steps.yml`.', 'legacy CLI access sentence');
    content = replaceRequired(content, '| `openspec status [--change <name>] [--json]` | Show artifact progress for a change |', '| `openspec status [--json]` | Show artifact progress for active changes |', 'legacy status command row');
    content = replaceRequired(content, '| `openspec instructions [artifact] [--change <name>] [--json]` | Get next-step instructions for a change |', '| `openspec instructions [--json]` | Get next-step instructions for a change |', 'legacy instructions command row');
    return replaceRequired(content, '- `openspec/config.yaml` — Project configuration', `- \`openspec/config.yaml\` — Project configuration
- \`openspec/explorations/\` — Exploration documents`, 'legacy exploration directory');
}
function replaceRequired(content, searchValue, replaceValue, label) {
    if (!content.includes(searchValue)) {
        throw new Error(`Cannot build Copilot cloud file content: missing ${label}`);
    }
    return content.replace(searchValue, replaceValue);
}
/**
 * File paths (relative to project root) for the generated files.
 */
export const COPILOT_CLOUD_FILES = {
    setupSteps: path.join('.github', 'workflows', 'copilot-setup-steps.yml'),
    agent: path.join('.github', 'agents', 'openspec.agent.md'),
};
const COPILOT_AGENT_ALTERNATE_FILE = path.join('.github', 'agents', 'openspec.md');
const COPILOT_CLOUD_FILE_CONTENTS = {
    [COPILOT_CLOUD_FILES.setupSteps]: generateCopilotSetupSteps(),
    [COPILOT_CLOUD_FILES.agent]: generateCopilotAgentFile(),
};
function getLegacyCopilotCloudFileContents(relPath) {
    if (relPath === COPILOT_CLOUD_FILES.setupSteps) {
        return [generateCopilotSetupStepsBody()];
    }
    return [
        generateCopilotAgentFileBody(),
        generatePreviousCopilotAgentFileBody(),
        generatePreviousCopilotAgentFileBody(true),
        generateLegacyCopilotAgentFileBody(),
    ];
}
function normalizeLineEndings(content) {
    return content.replace(/\r\n/g, '\n');
}
function isCurrentCopilotCloudFile(relPath, content) {
    return normalizeLineEndings(content) === COPILOT_CLOUD_FILE_CONTENTS[relPath];
}
function isLegacyCopilotCloudFile(relPath, content) {
    return getLegacyCopilotCloudFileContents(relPath).includes(normalizeLineEndings(content));
}
function isManagedCopilotCloudFile(relPath, content) {
    return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content);
}
async function reconcileCopilotCloudFile(fullPath, relPath) {
    const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath];
    if (!(await FileSystemUtils.fileExists(fullPath))) {
        await FileSystemUtils.writeFile(fullPath, currentContent);
        return true;
    }
    const existingContent = await FileSystemUtils.readFile(fullPath);
    if (isCurrentCopilotCloudFile(relPath, existingContent)) {
        return false;
    }
    if (!isLegacyCopilotCloudFile(relPath, existingContent)) {
        return false;
    }
    await FileSystemUtils.writeFile(fullPath, currentContent);
    return true;
}
async function assertCreatableFilePath(filePath) {
    let candidate = path.dirname(filePath);
    while (true) {
        try {
            const stats = await fs.stat(candidate);
            if (!stats.isDirectory()) {
                throw new Error(`Parent path is not a directory: ${candidate}`);
            }
            return;
        }
        catch (error) {
            if (error.code !== 'ENOENT') {
                throw error;
            }
        }
        const parent = path.dirname(candidate);
        if (parent === candidate) {
            throw new Error(`Cannot resolve a directory ancestor for: ${filePath}`);
        }
        candidate = parent;
    }
}
async function assertMissingOrRegularFile(filePath) {
    try {
        const stats = await fs.stat(filePath);
        if (!stats.isFile()) {
            throw new Error(`Managed Copilot path is not a regular file: ${filePath}`);
        }
    }
    catch (error) {
        if (error.code !== 'ENOENT') {
            throw error;
        }
    }
}
async function classifyCopilotAgentReconciliation(agentPath, alternateAgentPath) {
    if (!(await FileSystemUtils.fileExists(alternateAgentPath))) {
        return 'reconcile';
    }
    if (!(await FileSystemUtils.fileExists(agentPath))) {
        return 'skip';
    }
    const existingContent = await FileSystemUtils.readFile(agentPath);
    if (isManagedCopilotCloudFile(COPILOT_CLOUD_FILES.agent, existingContent)) {
        return 'remove-managed';
    }
    throw new Error(`Conflicting Copilot agent profiles: preserve either ${COPILOT_AGENT_ALTERNATE_FILE} or ${COPILOT_CLOUD_FILES.agent}`);
}
/**
 * Reconcile Copilot cloud agent files in the project directory.
 * Creates missing files and refreshes recognized legacy generated files while
 * preserving current generated content and user customizations.
 *
 * @returns Object indicating which files were written.
 */
export async function writeCopilotCloudFiles(projectPath) {
    const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.setupSteps);
    const agentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.agent);
    const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_AGENT_ALTERNATE_FILE);
    await assertCreatableFilePath(setupStepsPath);
    await assertCreatableFilePath(agentPath);
    await assertMissingOrRegularFile(setupStepsPath);
    await assertMissingOrRegularFile(agentPath);
    await assertMissingOrRegularFile(alternateAgentPath);
    const agentReconciliation = await classifyCopilotAgentReconciliation(agentPath, alternateAgentPath);
    const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps);
    let agentWritten = false;
    if (agentReconciliation === 'reconcile') {
        agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent);
    }
    else if (agentReconciliation === 'remove-managed') {
        await fs.unlink(agentPath);
    }
    return { setupStepsWritten, agentWritten };
}
/**
 * Remove copilot cloud agent files from the project directory.
 * Used when github-copilot is deselected.
 *
 * @returns Number of files removed.
 */
export async function removeCopilotCloudFiles(projectPath) {
    let removed = 0;
    const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({
        relPath,
        fullPath: FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath),
    }));
    for (const { fullPath } of managedPaths) {
        await assertMissingOrRegularFile(fullPath);
    }
    for (const { relPath, fullPath } of managedPaths) {
        if (await FileSystemUtils.fileExists(fullPath)) {
            const content = await FileSystemUtils.readFile(fullPath);
            if (!isManagedCopilotCloudFile(relPath, content)) {
                continue;
            }
            await fs.unlink(fullPath);
            removed++;
        }
    }
    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) {
    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) {
    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) {
    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, value) {
    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 = 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());
}
/**
 * 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) {
    const collisions = [];
    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) {
    const present = [];
    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;
}
//# sourceMappingURL=cloud-agent.js.map