diff --git a/.gitignore b/.gitignore index 301aca457..9c9974b60 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ skill-plan.md skill-strategy.md apm_modules/ build/tmp/ +scout-pipeline-result.png diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ef73604..37dd70a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Native Cursor IDE integration — `apm install` deploys primitives to `.cursor/` when the directory exists: instructions→rules (`.mdc`), agents, skills, hooks (`hooks.json`), and MCP (`mcp.json`) +- Native OpenCode integration — `apm install` deploys primitives to `.opencode/` when the directory exists: agents, commands (from prompts), skills, and MCP (`opencode.json`) — inspired by @timvw (#257, #306) - `TargetProfile` data layer (`src/apm_cli/integration/targets.py`) — data-driven target definitions for scalable multi-target architecture - `CursorClientAdapter` for MCP server management in `.cursor/mcp.json` +- `OpenCodeClientAdapter` for MCP server management in `opencode.json` ## [0.7.9] - 2026-03-13 diff --git a/docs/src/content/docs/enterprise/making-the-case.md b/docs/src/content/docs/enterprise/making-the-case.md index cf0cd78b4..8b98ec916 100644 --- a/docs/src/content/docs/enterprise/making-the-case.md +++ b/docs/src/content/docs/enterprise/making-the-case.md @@ -52,7 +52,7 @@ An internal advocacy toolkit for APM. Each section is self-contained and designe Plugins handle single-tool installation for a single AI platform. APM adds capabilities that plugins do not provide: -- **Cross-tool composition.** One manifest manages configuration for Copilot, Claude, Cursor, and any other agent runtime simultaneously. +- **Cross-tool composition.** One manifest manages configuration for Copilot, Claude, Cursor, OpenCode, and any other agent runtime simultaneously. - **Consumer-side lock files.** Plugins install the latest version. APM pins exact versions so your team stays synchronized. - **CI enforcement.** There is no plugin equivalent of `apm audit --ci` for drift detection in a CI pipeline. - **Multi-source dependency resolution.** APM resolves transitive dependencies across packages from multiple git hosts. diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index c39790894..6af600fb9 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -12,7 +12,7 @@ This page documents APM's security posture for enterprise security reviews, comp APM is a build-time dependency manager for AI prompts and configuration. It performs four operations: 1. **Resolves git repositories** — clones or sparse-checks-out packages from GitHub or Azure DevOps. -2. **Deploys static files** — copies markdown, JSON, and YAML files into project directories (`.github/`, `.claude/`, `.cursor/`). +2. **Deploys static files** — copies markdown, JSON, and YAML files into project directories (`.github/`, `.claude/`, `.cursor/`, `.opencode/`). 3. **Generates compiled output** — produces `AGENTS.md`, `CLAUDE.md`, and similar files from templates and prompts. 4. **Records a lock file** — writes `apm.lock.yaml` with exact commit SHAs for every resolved dependency. @@ -66,7 +66,7 @@ APM deploys files only to controlled subdirectories within the project root. Thr All deploy paths are validated before any file operation. The `validate_deploy_path` check enforces three rules: 1. **No `..` segments.** Any path containing `..` is rejected outright. -2. **Allowed prefixes only.** Paths must start with an allowed prefix (`.github/`, `.claude/`, or `.cursor/`). +2. **Allowed prefixes only.** Paths must start with an allowed prefix (`.github/`, `.claude/`, `.cursor/`, or `.opencode/`). 3. **Resolution containment.** The fully resolved path must remain within the project root directory. A path must pass all three checks. Failure on any check prevents the file from being written. @@ -188,7 +188,7 @@ No. APM makes no network requests beyond git clone/fetch operations to resolve d ### Can a malicious package write files outside the project? -No. All deploy paths are validated against the project root using path traversal checks, prefix allowlists, and resolved path containment. Symlinks are skipped entirely. A package cannot write files outside `.github/`, `.claude/`, or `.cursor/` within the project root. +No. All deploy paths are validated against the project root using path traversal checks, prefix allowlists, and resolved path containment. Symlinks are skipped entirely. A package cannot write files outside `.github/`, `.claude/`, `.cursor/`, or `.opencode/` within the project root. ### Can a transitive dependency inject MCP servers? diff --git a/docs/src/content/docs/enterprise/teams.md b/docs/src/content/docs/enterprise/teams.md index 6b10938d0..097a359b6 100644 --- a/docs/src/content/docs/enterprise/teams.md +++ b/docs/src/content/docs/enterprise/teams.md @@ -13,7 +13,7 @@ Every configuration change is auditable. ## The problem at scale -Consider a mid-to-large engineering organization: 50 repositories, 200 developers, three AI coding tools (Copilot, Claude, Cursor). +Consider a mid-to-large engineering organization: 50 repositories, 200 developers, four AI coding tools (Copilot, Claude, Cursor, OpenCode). Without centralized configuration management, a predictable set of problems emerges: @@ -61,7 +61,7 @@ Two developers running `apm install` from the same lock file get identical confi ### Install -`apm install` reads the lock file and deploys configuration into the native formats expected by each tool — `.github/` for Copilot, `.claude/` for Claude, `.cursor/` for Cursor. APM generates static files and then gets out of the way. There is no runtime, no daemon, no background process. +`apm install` reads the lock file and deploys configuration into the native formats expected by each tool — `.github/` for Copilot, `.claude/` for Claude, `.cursor/` for Cursor, `.opencode/` for OpenCode. APM generates static files and then gets out of the way. There is no runtime, no daemon, no background process. ### Audit diff --git a/docs/src/content/docs/getting-started/first-package.md b/docs/src/content/docs/getting-started/first-package.md index 01b302ede..336a7d301 100644 --- a/docs/src/content/docs/getting-started/first-package.md +++ b/docs/src/content/docs/getting-started/first-package.md @@ -105,13 +105,13 @@ APM automatically: ## 7. Optional: Compile for Other Tools -If you use tools beyond GitHub Copilot, Claude, and Cursor (which read deployed primitives natively), generate compiled instruction files: +If you use tools beyond GitHub Copilot, Claude, Cursor, and OpenCode (which read deployed primitives natively), generate compiled instruction files: ```bash apm compile ``` -This produces `AGENTS.md` (for Codex) and `CLAUDE.md` for tools that need a single instructions file. Copilot, Claude, and Cursor users can skip this step. +This produces `AGENTS.md` (for Codex, Gemini) and `CLAUDE.md` for tools that need a single instructions file. Copilot, Claude, and Cursor users can skip this step — OpenCode users need `apm compile` only if their packages include instructions (OpenCode reads `AGENTS.md` for those). ## Next Steps diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 3284300e1..ca52e4c89 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -82,17 +82,22 @@ my-project/ commands/ apm-sample-package/ ... - .cursor/ # only if .cursor/ already exists + .cursor/ rules/ design-standards.mdc agents/ design-reviewer.md + .opencode/ + agents/ + design-reviewer.md + commands/ + design-review.md ``` Three things happened: 1. The package was downloaded into `apm_modules/` (like `node_modules/`). -2. Instructions, agents, and skills were deployed to `.github/`, `.claude/`, and `.cursor/` (when present) -- the native directories that GitHub Copilot, Claude, and Cursor read from. +2. Instructions, agents, and skills were deployed to `.github/`, `.claude/`, `.cursor/`, and `.opencode/` (when present) -- the native directories that GitHub Copilot, Claude, Cursor, and OpenCode read from. 3. A lockfile (`apm.lock.yaml`) was created, pinning the exact commit so every team member gets identical configuration. Your `apm.yml` now tracks the dependency: @@ -107,7 +112,7 @@ dependencies: ## That's it -Open your editor. GitHub Copilot, Claude, and Cursor pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package. +Open your editor. GitHub Copilot, Claude, Cursor, and OpenCode pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package. This is the core idea: **packages define what your AI agent knows, and `apm install` puts that knowledge exactly where your tools expect it.** diff --git a/docs/src/content/docs/guides/compilation.md b/docs/src/content/docs/guides/compilation.md index d55806cb9..ec0b9e556 100644 --- a/docs/src/content/docs/guides/compilation.md +++ b/docs/src/content/docs/guides/compilation.md @@ -4,7 +4,7 @@ sidebar: order: 1 --- -Compilation is **optional for most users**. If your team uses GitHub Copilot, Claude, or Cursor, `apm install` deploys all primitives in their native format -- you can skip this guide entirely. `apm compile` is for teams that use Codex, Gemini, or other tools that read single-root-file formats like `AGENTS.md` or `CLAUDE.md`. It is also useful when you want a consolidated view of all instructions in one file. +Compilation is **optional for most users**. If your team uses GitHub Copilot, Claude, or Cursor, `apm install` deploys all primitives in their native format -- you can skip this guide entirely. For OpenCode, `apm install` deploys agents, commands, skills, and MCP, but instructions require `apm compile` to generate the `AGENTS.md` that OpenCode reads. `apm compile` is also needed for Codex, Gemini, or other tools that read single-root-file formats. **Solving the AI agent scalability problem through constraint satisfaction optimization** @@ -42,14 +42,14 @@ target: copilot # or vscode, claude, or all | Target | Files Generated | Consumers | |--------|-----------------|-----------| -| `copilot` | `AGENTS.md` | GitHub Copilot, Cursor, Codex, Gemini | +| `copilot` | `AGENTS.md` | GitHub Copilot, Cursor, OpenCode, Codex, Gemini | | `claude` | `CLAUDE.md` | Claude Code, Claude Desktop | | `all` | Both `AGENTS.md` and `CLAUDE.md` | Universal compatibility | | `minimal` | `AGENTS.md` only | Works everywhere, no folder integration | > **Aliases**: `vscode` and `agents` are accepted as aliases for `copilot`. -> **Note**: `AGENTS.md` and `CLAUDE.md` contain **only instructions** (grouped by `applyTo` patterns). Prompts, agents, commands, hooks, and skills are integrated by `apm install`, not `apm compile`. See the [Integrations Guide](../../integrations/ide-tool-integration/) for details on how `apm install` populates `.github/prompts/`, `.github/agents/`, `.github/skills/`, `.claude/commands/`, `.cursor/rules/`, and `.cursor/agents/`. +> **Note**: `AGENTS.md` and `CLAUDE.md` contain **only instructions** (grouped by `applyTo` patterns). Prompts, agents, commands, hooks, and skills are integrated by `apm install`, not `apm compile`. See the [Integrations Guide](../../integrations/ide-tool-integration/) for details on how `apm install` populates `.github/prompts/`, `.github/agents/`, `.github/skills/`, `.claude/commands/`, `.cursor/rules/`, `.cursor/agents/`, `.opencode/agents/`, and `.opencode/commands/`. ### How It Works @@ -435,10 +435,11 @@ Different AI tools get different levels of support from `apm install` vs `apm co | GitHub Copilot | `.github/instructions/`, `.github/prompts/`, agents, hooks, plugins, MCP | `AGENTS.md` (optional) | **Full** | | Claude | `.claude/` commands, skills, MCP | `CLAUDE.md` | **Full** | | Cursor | `.cursor/rules/`, `.cursor/agents/`, `.cursor/skills/`, `.cursor/hooks.json`, `.cursor/mcp.json` | `AGENTS.md` (optional) | **Full** | +| OpenCode | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/`, `opencode.json` (MCP) | Via `AGENTS.md` | **Full** | | Codex CLI | -- | `AGENTS.md` | Instructions via compile | | Gemini | -- | `GEMINI.md` | Instructions via compile | -For Copilot, Claude, and Cursor users, `apm install` handles everything natively. Compilation is the bridge that brings instruction support to tools that do not yet have first-class APM integration. +For Copilot, Claude, and Cursor users, `apm install` handles everything natively. OpenCode users should also run `apm compile` to generate `AGENTS.md` for instructions. Compilation is the bridge that brings instruction support to tools that do not yet have first-class APM integration. ## Theoretical Foundations diff --git a/docs/src/content/docs/guides/pack-distribute.md b/docs/src/content/docs/guides/pack-distribute.md index 036ad0aaa..90c856b5d 100644 --- a/docs/src/content/docs/guides/pack-distribute.md +++ b/docs/src/content/docs/guides/pack-distribute.md @@ -62,7 +62,7 @@ apm pack --dry-run | Flag | Default | Description | |------|---------|-------------| | `--format` | `apm` | Bundle format (`apm` or `plugin`) | -| `-t, --target` | auto-detect | File filter: `copilot`, `vscode`, `claude`, `cursor`, `all`. `vscode` is an alias for `copilot` | +| `-t, --target` | auto-detect | File filter: `copilot`, `vscode`, `claude`, `cursor`, `opencode`, `all`. `vscode` is an alias for `copilot` | | `--archive` | off | Produce `.tar.gz` instead of directory | | `-o, --output` | `./build` | Output directory | | `--dry-run` | off | List files without writing | @@ -77,7 +77,8 @@ The target flag controls which deployed files are included based on path prefix: | `vscode` | Alias for `copilot` | | `claude` | Paths starting with `.claude/` | | `cursor` | Paths starting with `.cursor/` | -| `all` | `.github/`, `.claude/`, and `.cursor/` | +| `opencode` | Paths starting with `.opencode/` | +| `all` | `.github/`, `.claude/`, `.cursor/`, and `.opencode/` | When no target is specified, APM auto-detects from the `target` field in `apm.yml`, falling back to `all`. @@ -134,6 +135,11 @@ build/my-project-1.0.0/ ... agents/ ... + .opencode/ + agents/ + ... + commands/ + ... apm.lock.yaml ``` diff --git a/docs/src/content/docs/guides/skills.md b/docs/src/content/docs/guides/skills.md index 0eef60777..db7052f65 100644 --- a/docs/src/content/docs/guides/skills.md +++ b/docs/src/content/docs/guides/skills.md @@ -54,6 +54,7 @@ APM copies skills directly to `.github/skills/` (primary), `.claude/skills/`, an - **Primary**: `.github/skills/{skill-name}/` — Works with Copilot, Codex, Gemini - **Compatibility**: `.claude/skills/{skill-name}/` — Only if `.claude/` folder already exists - **Compatibility**: `.cursor/skills/{skill-name}/` — Only if `.cursor/` folder already exists +- **Compatibility**: `.opencode/skills/{skill-name}/` — Only if `.opencode/` folder already exists ### Skill Folder Naming @@ -293,6 +294,7 @@ APM decides where to output skills based on project structure: | `.github/` exists | `.github/skills/{skill-name}/SKILL.md` | | `.claude/` also exists | Also copies to `.claude/skills/{skill-name}/SKILL.md` | | `.cursor/` also exists | Also copies to `.cursor/skills/{skill-name}/SKILL.md` | +| `.opencode/` also exists | Also copies to `.opencode/skills/{skill-name}/SKILL.md` | | Neither exists | Creates `.github/skills/` | Override with: diff --git a/docs/src/content/docs/integrations/ci-cd.md b/docs/src/content/docs/integrations/ci-cd.md index d99daad76..d7cdbf180 100644 --- a/docs/src/content/docs/integrations/ci-cd.md +++ b/docs/src/content/docs/integrations/ci-cd.md @@ -67,13 +67,13 @@ This step is not needed if your team only uses GitHub Copilot and Claude, which ### Verify Deployed Primitives -To ensure `.github/`, `.claude/`, and `.cursor/` integration files stay in sync with `apm.yml`, add a drift check: +To ensure `.github/`, `.claude/`, `.cursor/`, and `.opencode/` integration files stay in sync with `apm.yml`, add a drift check: ```yaml - name: Check APM integration drift run: | apm install - if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/)" ]; then + if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/)" ]; then echo "APM integration files are out of date. Run 'apm install' and commit." exit 1 fi @@ -171,6 +171,6 @@ See the [Pack & Distribute guide](../../guides/pack-distribute/) for the full wo - **Pin APM version** in CI to avoid unexpected changes: `pip install apm-cli==0.7.7` - **Commit `apm.lock.yaml`** so CI resolves the same dependency versions as local development -- **Commit `.github/`, `.claude/`, and `.cursor/` deployed files** so contributors and cloud-based Copilot get agent context without running `apm install` +- **Commit `.github/`, `.claude/`, `.cursor/`, and `.opencode/` deployed files** so contributors and cloud-based Copilot get agent context without running `apm install` - **If using `apm compile`** (for Codex, Gemini), run it in CI and fail the build if the output differs from what's committed - **Use `GITHUB_APM_PAT`** for private dependencies; never use the default `GITHUB_TOKEN` for cross-repo access diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index a408ab2e0..66ee985b3 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -148,7 +148,7 @@ apm install microsoft/apm-sample-package ### Optional: Compiled Context with AGENTS.md -For tools that do not support granular primitive discovery (such as Codex or Gemini), `apm compile` produces an `AGENTS.md` file that merges instructions into a single document. This is not needed for GitHub Copilot, Claude, or Cursor, which read deployed primitives natively. +For tools that do not support granular primitive discovery (such as Codex or Gemini), `apm compile` produces an `AGENTS.md` file that merges instructions into a single document. This is not needed for GitHub Copilot, Claude, or Cursor, which read per-file instructions natively. OpenCode also reads `AGENTS.md`, so run `apm compile` to deploy instructions there. ```bash # Compile all local and dependency instructions into AGENTS.md @@ -184,6 +184,22 @@ When you run `apm install`, APM integrates package primitives into Claude's nati | `.claude/skills/{folder}/` | Skills from packages with `SKILL.md` or `.apm/` primitives | | `.claude/settings.json` (hooks key) | Hooks from installed packages (merged into settings) | +### OpenCode (`.opencode/`) + +APM natively integrates with OpenCode when a `.opencode/` directory exists in your project. Run `apm install` and APM automatically deploys primitives to OpenCode's native format: + +| APM Primitive | OpenCode Destination | Format | +|---|---|---| +| Agents (`.agent.md`) | `.opencode/agents/*.md` | Markdown with YAML frontmatter | +| Prompts (`.prompt.md`) | `.opencode/commands/*.md` | Converted to command format | +| Skills (`SKILL.md`) | `.opencode/skills/{name}/SKILL.md` | Identical (agentskills.io standard) | +| MCP servers | `opencode.json` | `mcp` key with `command` array, `environment` | +| Instructions | Via `AGENTS.md` | Read natively by OpenCode | + +**Setup**: Create a `.opencode/` directory in your project root, then run `apm install`. APM detects the directory and deploys automatically. OpenCode reads `AGENTS.md` natively for instructions. + +> **Note**: OpenCode does not support hooks. + #### Cursor (`.cursor/`) | Location | Purpose | @@ -286,7 +302,7 @@ apm install anthropics/claude-plugins-official/plugins/hookify ### Optional: Target-Specific Compilation -Compilation is optional for Copilot, Claude, and Cursor, which read deployed primitives natively. Use it when targeting tools like Codex or Gemini, or when you want a single merged instruction file: +Compilation is optional for Copilot, Claude, and Cursor, which read per-file instructions natively. For OpenCode, run `apm compile` to generate `AGENTS.md` (OpenCode's instruction source). Also use it when targeting Codex or Gemini: ```bash # Generate all formats (default) diff --git a/docs/src/content/docs/introduction/how-it-works.md b/docs/src/content/docs/introduction/how-it-works.md index 766d4c3ae..f4baa7467 100644 --- a/docs/src/content/docs/introduction/how-it-works.md +++ b/docs/src/content/docs/introduction/how-it-works.md @@ -123,7 +123,7 @@ graph TD 4. **AI Coding Agents** - Execute your compiled workflows (Copilot, Cursor, etc.) 5. **Supporting Infrastructure** - MCP servers for tools, LLM models for execution -GitHub Copilot and Claude read the deployed primitives natively. Cursor also receives native integration when `.cursor/` exists (rules, agents, skills, hooks, MCP). For other tools (Codex, Gemini), `apm compile` generates an `agents.md` instruction file they can consume. +GitHub Copilot and Claude read the deployed primitives natively. Cursor and OpenCode also receive native integration when `.cursor/` or `.opencode/` exists. For other tools (Codex, Gemini), `apm compile` generates an `agents.md` instruction file they can consume. ## The Three Layers Explained @@ -271,6 +271,7 @@ APM auto-detects the target based on project structure (`.github/` or `.claude/` For tools that read a single instructions file, `apm compile` merges your primitives into a portable document the tool can consume. This gives you instruction-level support rather than full primitive integration. - **Cursor** - native integration to `.cursor/rules/`, `.cursor/agents/`, `.cursor/skills/`, `.cursor/hooks.json`, `.cursor/mcp.json` +- **OpenCode** - native integration to `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/`, `opencode.json` (MCP) - **Codex CLI** - compiled to `AGENTS.md` - **Gemini** - compiled to `GEMINI.md` diff --git a/docs/src/content/docs/introduction/what-is-apm.md b/docs/src/content/docs/introduction/what-is-apm.md index c98deb8eb..75eb6f2b8 100644 --- a/docs/src/content/docs/introduction/what-is-apm.md +++ b/docs/src/content/docs/introduction/what-is-apm.md @@ -12,7 +12,7 @@ AI agent configuration has no equivalent. Until now. ## What is agent package management? -AI coding agents — GitHub Copilot, Claude, Cursor, Codex, Gemini — are only as +AI coding agents — GitHub Copilot, Claude, Cursor, OpenCode, Codex, Gemini — are only as good as the context they receive. That context is made up of instructions, skills, prompts, agent definitions, hooks, plugins, and MCP server configurations. @@ -148,6 +148,7 @@ supported tool: | GitHub Copilot | `.github/instructions/`, `.github/prompts/`, agents, hooks, plugins, MCP | `AGENTS.md` (optional) | **Full** | | Claude | `.claude/` commands, skills, MCP | `CLAUDE.md` | **Full** | | Cursor | `.cursor/rules/`, `.cursor/agents/`, skills, hooks, MCP | `.cursor/rules/` (also via compile) | **Full** | +| OpenCode | `.opencode/agents/`, `.opencode/commands/`, skills, MCP | Via `AGENTS.md` | **Full** | | Codex CLI | — | `AGENTS.md` | Instructions via compile | | Gemini | — | `GEMINI.md` | Instructions via compile | @@ -197,7 +198,7 @@ underneath: ``` +--------------------------------------------------+ | AI Coding Tools | -| (Copilot, Claude, Cursor, Codex, Gemini) | +| (Copilot, Claude, Cursor, OpenCode, Codex, Gemini)| +--------------------------------------------------+ | Plugin / Extension Systems | | (tool-specific capabilities) | @@ -222,6 +223,7 @@ APM: - Your `AGENTS.md` still works with Copilot and Codex - Your `CLAUDE.md` still works with Claude - Your `.cursor/rules/` still work with Cursor +- Your `.opencode/` files still work with OpenCode - Your `.github/prompts/` still work with Copilot APM adds a dependency management layer. It does not add a runtime dependency. diff --git a/docs/src/content/docs/introduction/why-apm.md b/docs/src/content/docs/introduction/why-apm.md index db23e0f64..79e22d3f3 100644 --- a/docs/src/content/docs/introduction/why-apm.md +++ b/docs/src/content/docs/introduction/why-apm.md @@ -35,7 +35,7 @@ dependencies: Run `apm install` and APM: - **Resolves transitive dependencies** — if package A depends on package B, both are installed automatically. -- **Integrates primitives** — instructions, prompts, agents, and skills are deployed to `.github/`, with equivalent primitives to `.claude/` and `.cursor/` when those directories exist. GitHub Copilot, Claude, and Cursor read these natively. +- **Integrates primitives** — instructions, prompts, agents, and skills are deployed to `.github/`, with equivalent primitives to `.claude/`, `.cursor/`, and `.opencode/` when those directories exist. GitHub Copilot, Claude, Cursor, and OpenCode read these natively. - **Bridges other tools** — for Codex, Gemini, and other tools without native integration, `apm compile` generates compatible instruction files (`AGENTS.md`, `CLAUDE.md`). ## APM vs. Manual Setup diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 16d7092c4..61e821932 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -192,6 +192,7 @@ APM automatically detects which integrations to enable based on your project str - **VSCode integration**: Enabled when `.github/` directory exists - **Claude integration**: Enabled when `.claude/` directory exists - **Cursor integration**: Enabled when `.cursor/` directory exists +- **OpenCode integration**: Enabled when `.opencode/` directory exists - All integrations can coexist in the same project **VSCode Integration (`.github/` present):** @@ -243,7 +244,7 @@ Skills are copied directly to target directories: └─ 3 commands integrated → .claude/commands/ ``` -This makes all package primitives available in VSCode, Cursor, Claude Code, and compatible editors for immediate use with your coding agents. +This makes all package primitives available in VSCode, Cursor, OpenCode, Claude Code, and compatible editors for immediate use with your coding agents. ### `apm uninstall` - Remove APM packages @@ -289,6 +290,9 @@ apm uninstall microsoft/apm-sample-package --dry-run | Cursor agents | `.cursor/agents/*.md` | | Cursor skills | `.cursor/skills/{folder-name}/` | | Cursor hooks | `.cursor/hooks.json` (hooks key cleaned) | +| OpenCode agents | `.opencode/agents/*.md` | +| OpenCode commands | `.opencode/commands/*.md` | +| OpenCode skills | `.opencode/skills/{folder-name}/` | | Lockfile entries | `apm.lock.yaml` (removed packages + orphaned transitives) | **Behavior:** @@ -335,7 +339,7 @@ apm pack [OPTIONS] **Options:** - `-o, --output PATH` - Output directory (default: `./build`) -- `-t, --target [copilot|vscode|claude|all]` - Filter files by target. Auto-detects from `apm.yml` if not specified. `vscode` is an alias for `copilot` +- `-t, --target [copilot|vscode|claude|cursor|opencode|all]` - Filter files by target. Auto-detects from `apm.yml` if not specified. `vscode` is an alias for `copilot` - `--archive` - Produce a `.tar.gz` archive instead of a directory - `--dry-run` - List files that would be packed without writing anything - `--format [apm|plugin]` - Bundle format (default: `apm`) @@ -369,7 +373,9 @@ apm pack -o dist/ |--------|------------------------------| | `vscode` | `.github/` | | `claude` | `.claude/` | -| `all` | both | +| `cursor` | `.cursor/` | +| `opencode` | `.opencode/` | +| `all` | all of the above | **Enriched lockfile example:** ```yaml diff --git a/src/apm_cli/adapters/client/opencode.py b/src/apm_cli/adapters/client/opencode.py new file mode 100644 index 000000000..e28c4d6ad --- /dev/null +++ b/src/apm_cli/adapters/client/opencode.py @@ -0,0 +1,157 @@ +"""OpenCode implementation of MCP client adapter. + +OpenCode uses ``opencode.json`` at the project root with an ``mcp`` key. +The schema differs from VSCode/Cursor: + +.. code-block:: json + + { + "mcp": { + "server-name": { + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-foo"], + "environment": { "KEY": "value" }, + "enabled": true + } + } + } + +Key differences from Copilot/Cursor: +- Config file: ``opencode.json`` (not ``mcp.json``) +- Wrapper key: ``mcp`` (not ``mcpServers``) +- Command format: single array ``command`` (not ``command`` + ``args``) +- Env key: ``environment`` (not ``env``) + +APM only writes to ``opencode.json`` when the ``.opencode/`` directory +already exists — OpenCode support is opt-in. +""" + +import json +import os +from pathlib import Path + +from .copilot import CopilotClientAdapter + + +class OpenCodeClientAdapter(CopilotClientAdapter): + """OpenCode MCP client adapter. + + Converts the standard Copilot config format into OpenCode's schema + and writes to ``opencode.json`` in the project root. + """ + + def get_config_path(self): + """Return the path to ``opencode.json`` in the repository root.""" + return str(Path(os.getcwd()) / "opencode.json") + + def update_config(self, config_updates, enabled=True): + """Merge *config_updates* into the ``mcp`` section of ``opencode.json``. + + The ``.opencode/`` directory must already exist; if it does not, this + method returns silently (opt-in behaviour). + + Translates Copilot-format entries (``command``/``args``/``env``) into + OpenCode format (``command`` array / ``environment``). + """ + opencode_dir = Path(os.getcwd()) / ".opencode" + if not opencode_dir.is_dir(): + return + + config_path = Path(self.get_config_path()) + current_config = self.get_current_config() + if "mcp" not in current_config: + current_config["mcp"] = {} + + for name, copilot_entry in config_updates.items(): + current_config["mcp"][name] = self._to_opencode_format(copilot_entry, enabled=enabled) + + with open(config_path, "w", encoding="utf-8") as f: + json.dump(current_config, f, indent=2) + + def get_current_config(self): + """Read the current ``opencode.json`` contents.""" + config_path = self.get_config_path() + if not os.path.exists(config_path): + return {} + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + def configure_mcp_server( + self, + server_url, + server_name=None, + enabled=True, + env_overrides=None, + server_info_cache=None, + runtime_vars=None, + ): + """Configure an MCP server in ``opencode.json``. + + Delegates to the parent for config formatting, then converts to + OpenCode schema before writing. + """ + if not server_url: + print("Error: server_url cannot be empty") + return False + + opencode_dir = Path(os.getcwd()) / ".opencode" + if not opencode_dir.is_dir(): + return False + + try: + if server_info_cache and server_url in server_info_cache: + server_info = server_info_cache[server_url] + else: + server_info = self.registry_client.find_server_by_reference(server_url) + + if not server_info: + print(f"Error: MCP server '{server_url}' not found in registry") + return False + + if server_name: + config_key = server_name + elif "/" in server_url: + config_key = server_url.split("/")[-1] + else: + config_key = server_url + + server_config = self._format_server_config( + server_info, env_overrides, runtime_vars + ) + self.update_config({config_key: server_config}, enabled=enabled) + + print( + f"Successfully configured MCP server '{config_key}' for OpenCode" + ) + return True + + except Exception as e: + print(f"Error configuring MCP server: {e}") + return False + + @staticmethod + def _to_opencode_format(copilot_entry: dict, enabled: bool = True) -> dict: + """Convert a Copilot-format server config to OpenCode format. + + Copilot: ``{"command": "npx", "args": ["-y", "pkg"], "env": {...}}`` + OpenCode: ``{"type": "local", "command": ["npx", "-y", "pkg"], + "environment": {...}, "enabled": true}`` + """ + entry: dict = {"type": "local", "enabled": enabled} + + cmd = copilot_entry.get("command", "") + args = copilot_entry.get("args", []) + if cmd: + entry["command"] = [cmd] + list(args) + elif "url" in copilot_entry: + entry["type"] = "remote" + entry["url"] = copilot_entry["url"] + + env = copilot_entry.get("env") or {} + if env: + entry["environment"] = env + + return entry diff --git a/src/apm_cli/bundle/packer.py b/src/apm_cli/bundle/packer.py index 6b4d211c5..4b4820918 100644 --- a/src/apm_cli/bundle/packer.py +++ b/src/apm_cli/bundle/packer.py @@ -18,7 +18,8 @@ "vscode": [".github/"], "claude": [".claude/"], "cursor": [".cursor/"], - "all": [".github/", ".claude/", ".cursor/"], + "opencode": [".opencode/"], + "all": [".github/", ".claude/", ".cursor/", ".opencode/"], } diff --git a/src/apm_cli/commands/compile.py b/src/apm_cli/commands/compile.py index f7ac5b1e4..2325016d7 100644 --- a/src/apm_cli/commands/compile.py +++ b/src/apm_cli/commands/compile.py @@ -250,9 +250,9 @@ def _recompile(self, changed_file): @click.option( "--target", "-t", - type=click.Choice(["vscode", "agents", "claude", "all"]), + type=click.Choice(["vscode", "agents", "claude", "opencode", "all"]), default=None, - help="Target platform: vscode/agents (AGENTS.md), claude (CLAUDE.md), or all. Auto-detects if not specified.", + help="Target platform: vscode/agents (AGENTS.md), claude (CLAUDE.md), opencode (AGENTS.md), or all. Auto-detects if not specified.", ) @click.option( "--dry-run", diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 2620e7028..ddd09fc96 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -514,6 +514,7 @@ def _integrate_package_primitives( *, integrate_vscode, integrate_claude, + integrate_opencode=False, prompt_integrator, agent_integrator, skill_integrator, @@ -542,7 +543,7 @@ def _integrate_package_primitives( deployed = result["deployed_files"] - if not (integrate_vscode or integrate_claude): + if not (integrate_vscode or integrate_claude or integrate_opencode): return result # --- prompts --- @@ -576,7 +577,7 @@ def _integrate_package_primitives( deployed.append(tp.relative_to(project_root).as_posix()) # --- skills --- - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: skill_result = skill_integrator.integrate_package_skill( package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, @@ -644,6 +645,19 @@ def _integrate_package_primitives( for tp in cursor_agent_result.target_paths: deployed.append(tp.relative_to(project_root).as_posix()) + # --- OpenCode agents (.opencode) --- + opencode_agent_result = agent_integrator.integrate_package_agents_opencode( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if opencode_agent_result.files_integrated > 0: + result["agents"] += opencode_agent_result.files_integrated + _rich_info(f" └─ {opencode_agent_result.files_integrated} agents integrated → .opencode/agents/") + result["links_resolved"] += opencode_agent_result.links_resolved + for tp in opencode_agent_result.target_paths: + deployed.append(tp.relative_to(project_root).as_posix()) + # --- commands (.claude) --- command_result = command_integrator.integrate_package_commands( package_info, project_root, @@ -659,6 +673,19 @@ def _integrate_package_primitives( for tp in command_result.target_paths: deployed.append(tp.relative_to(project_root).as_posix()) + # --- OpenCode commands (.opencode) --- + opencode_command_result = command_integrator.integrate_package_commands_opencode( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if opencode_command_result.files_integrated > 0: + result["commands"] += opencode_command_result.files_integrated + _rich_info(f" └─ {opencode_command_result.files_integrated} commands integrated → .opencode/commands/") + result["links_resolved"] += opencode_command_result.links_resolved + for tp in opencode_command_result.target_paths: + deployed.append(tp.relative_to(project_root).as_posix()) + # --- hooks --- if integrate_vscode: hook_result = hook_integrator.integrate_package_hooks( @@ -919,6 +946,7 @@ def _collect_descendants(node, visited=None): detect_target, should_integrate_vscode, should_integrate_claude, + should_integrate_opencode, get_target_description, ) @@ -947,6 +975,7 @@ def _collect_descendants(node, visited=None): # Determine which integrations to run based on detected target integrate_vscode = should_integrate_vscode(detected_target) integrate_claude = should_integrate_claude(detected_target) + integrate_opencode = should_integrate_opencode(detected_target) # Initialize integrators prompt_integrator = PromptIntegrator() @@ -1189,6 +1218,7 @@ def _collect_descendants(node, visited=None): package_info, project_root, integrate_vscode=integrate_vscode, integrate_claude=integrate_claude, + integrate_opencode=integrate_opencode, prompt_integrator=prompt_integrator, agent_integrator=agent_integrator, skill_integrator=skill_integrator, @@ -1273,7 +1303,7 @@ def _collect_descendants(node, visited=None): installed_count += 1 # Still need to integrate prompts for cached packages (zero-config behavior) - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: try: # Create PackageInfo from cached package from apm_cli.models.apm_package import ( @@ -1352,8 +1382,8 @@ def _collect_descendants(node, visited=None): if hasattr(cached_package_info, 'package_type') and cached_package_info.package_type: package_types[dep_key] = cached_package_info.package_type.value - # VSCode + Claude integration (prompts + agents) - if integrate_vscode or integrate_claude: + # VSCode + Claude + OpenCode integration (prompts + agents) + if integrate_vscode or integrate_claude or integrate_opencode: # Integrate prompts prompt_result = ( prompt_integrator.integrate_package_prompts( @@ -1402,9 +1432,9 @@ def _collect_descendants(node, visited=None): for tp in agent_result.target_paths: dep_deployed.append(tp.relative_to(project_root).as_posix()) - # Skill integration (works for both VSCode and Claude) + # Skill integration (works for VSCode, Claude, and OpenCode) # Skills go to .github/skills/ (primary) and .claude/skills/ (if .claude/ exists) - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: skill_result = skill_integrator.integrate_package_skill( cached_package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, @@ -1501,6 +1531,25 @@ def _collect_descendants(node, visited=None): for tp in cursor_agent_result.target_paths: dep_deployed.append(tp.relative_to(project_root).as_posix()) + # OpenCode agents (.opencode/agents/) — opt-in + opencode_agent_result = ( + agent_integrator.integrate_package_agents_opencode( + cached_package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + ) + if opencode_agent_result.files_integrated > 0: + total_agents_integrated += ( + opencode_agent_result.files_integrated + ) + _rich_info( + f" └─ {opencode_agent_result.files_integrated} agents integrated → .opencode/agents/" + ) + total_links_resolved += opencode_agent_result.links_resolved + for tp in opencode_agent_result.target_paths: + dep_deployed.append(tp.relative_to(project_root).as_posix()) + # Claude-specific integration (commands) if integrate_claude: command_result = ( @@ -1525,6 +1574,25 @@ def _collect_descendants(node, visited=None): for tp in command_result.target_paths: dep_deployed.append(tp.relative_to(project_root).as_posix()) + # OpenCode commands (.opencode/commands/) — opt-in + opencode_command_result = ( + command_integrator.integrate_package_commands_opencode( + cached_package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + ) + if opencode_command_result.files_integrated > 0: + total_commands_integrated += ( + opencode_command_result.files_integrated + ) + _rich_info( + f" └─ {opencode_command_result.files_integrated} commands integrated → .opencode/commands/" + ) + total_links_resolved += opencode_command_result.links_resolved + for tp in opencode_command_result.target_paths: + dep_deployed.append(tp.relative_to(project_root).as_posix()) + # Hook integration (target-aware) if integrate_vscode: hook_result = hook_integrator.integrate_package_hooks( @@ -1657,7 +1725,7 @@ def _collect_descendants(node, visited=None): _rich_info(f" └─ Package type: APM Package (apm.yml)") # Auto-integrate prompts and agents if enabled - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: try: # Integrate prompts + agents (dual-target: .github/ + .claude/) # Integrate prompts @@ -1708,9 +1776,9 @@ def _collect_descendants(node, visited=None): for tp in agent_result.target_paths: dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) - # Skill integration (works for both VSCode and Claude) + # Skill integration (works for VSCode, Claude, and OpenCode) # Skills go to .github/skills/ (primary) and .claude/skills/ (if .claude/ exists) - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: skill_result = skill_integrator.integrate_package_skill( package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, @@ -1807,6 +1875,25 @@ def _collect_descendants(node, visited=None): for tp in cursor_agent_result.target_paths: dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + # OpenCode agents (.opencode/agents/) — opt-in + opencode_agent_result = ( + agent_integrator.integrate_package_agents_opencode( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + ) + if opencode_agent_result.files_integrated > 0: + total_agents_integrated += ( + opencode_agent_result.files_integrated + ) + _rich_info( + f" └─ {opencode_agent_result.files_integrated} agents integrated → .opencode/agents/" + ) + total_links_resolved += opencode_agent_result.links_resolved + for tp in opencode_agent_result.target_paths: + dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + # Claude-specific integration (commands) if integrate_claude: command_result = ( @@ -1831,6 +1918,25 @@ def _collect_descendants(node, visited=None): for tp in command_result.target_paths: dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + # OpenCode commands (.opencode/commands/) — opt-in + opencode_command_result = ( + command_integrator.integrate_package_commands_opencode( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + ) + if opencode_command_result.files_integrated > 0: + total_commands_integrated += ( + opencode_command_result.files_integrated + ) + _rich_info( + f" └─ {opencode_command_result.files_integrated} commands integrated → .opencode/commands/" + ) + total_links_resolved += opencode_command_result.links_resolved + for tp in opencode_command_result.target_paths: + dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + # Hook integration (target-aware) if integrate_vscode: hook_result = hook_integrator.integrate_package_hooks( diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index db12afa5e..86dd04edb 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -21,7 +21,7 @@ @click.option( "--target", "-t", - type=click.Choice(["copilot", "vscode", "claude", "cursor", "all"]), + type=click.Choice(["copilot", "vscode", "claude", "cursor", "opencode", "all"]), default=None, help="Filter files by target (default: auto-detect). 'vscode' is an alias for 'copilot'.", ) diff --git a/src/apm_cli/commands/uninstall.py b/src/apm_cli/commands/uninstall.py index 116c5f8a1..d321cf641 100644 --- a/src/apm_cli/commands/uninstall.py +++ b/src/apm_cli/commands/uninstall.py @@ -422,7 +422,13 @@ def _find_transitive_orphans(lockfile, removed_urls): managed_files=_buckets["agents_cursor"] if _buckets else None) agents_cleaned += result.get("files_removed", 0) - if Path(".github/skills").exists() or Path(".claude/skills").exists() or Path(".cursor/skills").exists(): + if Path(".opencode/agents").exists(): + integrator = AgentIntegrator() + result = integrator.sync_integration_opencode(apm_package, project_root, + managed_files=_buckets["agents_opencode"] if _buckets else None) + agents_cleaned += result.get("files_removed", 0) + + if Path(".github/skills").exists() or Path(".claude/skills").exists() or Path(".cursor/skills").exists() or Path(".opencode/skills").exists(): integrator = SkillIntegrator() result = integrator.sync_integration(apm_package, project_root, managed_files=_buckets["skills"] if _buckets else None) @@ -434,6 +440,12 @@ def _find_transitive_orphans(lockfile, removed_urls): managed_files=_buckets["commands"] if _buckets else None) commands_cleaned = result.get("files_removed", 0) + if Path(".opencode/commands").exists(): + integrator = CommandIntegrator() + result = integrator.sync_integration_opencode(apm_package, project_root, + managed_files=_buckets["commands_opencode"] if _buckets else None) + commands_cleaned += result.get("files_removed", 0) + # Clean hooks (.github/hooks/ and .claude/settings.json) hook_integrator_cleanup = HookIntegrator() result = hook_integrator_cleanup.sync_integration(apm_package, project_root, diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 7eee09b67..2990134f9 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -1,17 +1,18 @@ """Target detection for auto-selecting compilation and integration targets. This module implements the auto-detection pattern for determining which agent -targets (VSCode/Copilot vs Claude) should be used based on existing project -structure and configuration. +targets (VSCode/Copilot, Claude, OpenCode) should be used based on existing +project structure and configuration. Detection priority (highest to lowest): 1. Explicit --target flag (always wins) 2. apm.yml target setting (top-level field) 3. Auto-detect from existing folders: - - .github/ exists AND .claude/ doesn't -> copilot (internal: "vscode") - - .claude/ exists AND .github/ doesn't -> claude - - Both exist -> all - - Neither exists -> minimal (AGENTS.md only, no folder integration) + - .github/ only -> copilot (internal: "vscode") + - .claude/ only -> claude + - .opencode/ only -> opencode + - Multiple target folders -> all + - None exist -> minimal (AGENTS.md only, no folder integration) "copilot" is the recommended user-facing target name. "vscode" and "agents" are accepted as aliases and map to the same internal value. @@ -21,10 +22,10 @@ from typing import Literal, Optional, Tuple # Valid target values (internal canonical form) -TargetType = Literal["vscode", "claude", "all", "minimal"] +TargetType = Literal["vscode", "claude", "opencode", "all", "minimal"] # User-facing target values (includes aliases accepted by CLI) -UserTargetType = Literal["copilot", "vscode", "agents", "claude", "all", "minimal"] +UserTargetType = Literal["copilot", "vscode", "agents", "claude", "opencode", "all", "minimal"] def detect_target( @@ -50,6 +51,8 @@ def detect_target( return "vscode", "explicit --target flag" elif explicit_target == "claude": return "claude", "explicit --target flag" + elif explicit_target == "opencode": + return "opencode", "explicit --target flag" elif explicit_target == "all": return "all", "explicit --target flag" @@ -59,22 +62,34 @@ def detect_target( return "vscode", "apm.yml target" elif config_target == "claude": return "claude", "apm.yml target" + elif config_target == "opencode": + return "opencode", "apm.yml target" elif config_target == "all": return "all", "apm.yml target" # Priority 3: Auto-detect from existing folders github_exists = (project_root / ".github").exists() claude_exists = (project_root / ".claude").exists() - - if github_exists and not claude_exists: + opencode_exists = (project_root / ".opencode").is_dir() + detected = [] + if github_exists: + detected.append(".github/") + if claude_exists: + detected.append(".claude/") + if opencode_exists: + detected.append(".opencode/") + + if len(detected) >= 2: + return "all", f"detected {' and '.join(detected)} folders" + elif github_exists: return "vscode", "detected .github/ folder" - elif claude_exists and not github_exists: + elif claude_exists: return "claude", "detected .claude/ folder" - elif github_exists and claude_exists: - return "all", "detected both .github/ and .claude/ folders" + elif opencode_exists: + return "opencode", "detected .opencode/ folder" else: - # Neither folder exists - minimal output - return "minimal", "no .github/ or .claude/ folder found" + # No known target folders exist - minimal output + return "minimal", "no .github/, .claude/, or .opencode/ folder found" def should_integrate_vscode(target: TargetType) -> bool: @@ -101,6 +116,18 @@ def should_integrate_claude(target: TargetType) -> bool: return target in ("claude", "all") +def should_integrate_opencode(target: TargetType) -> bool: + """Check if OpenCode integration should be performed. + + Args: + target: The detected or configured target + + Returns: + bool: True if OpenCode integration (agents, commands, skills) should run + """ + return target in ("opencode", "all") + + def should_compile_agents_md(target: TargetType) -> bool: """Check if AGENTS.md should be compiled. @@ -113,7 +140,7 @@ def should_compile_agents_md(target: TargetType) -> bool: Returns: bool: True if AGENTS.md should be generated """ - return target in ("vscode", "all", "minimal") + return target in ("vscode", "opencode", "all", "minimal") def should_compile_claude_md(target: TargetType) -> bool: @@ -144,7 +171,8 @@ def get_target_description(target: UserTargetType) -> str: descriptions = { "vscode": "AGENTS.md + .github/prompts/ + .github/agents/", "claude": "CLAUDE.md + .claude/commands/ + .claude/agents/ + .claude/skills/", - "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ (+ .cursor/ if present)", + "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", + "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ (+ .cursor/ .opencode/ if present)", "minimal": "AGENTS.md only (create .github/ or .claude/ for full integration)", } return descriptions.get(normalized, "unknown target") diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py index 3cb2dccf4..4f6295a86 100644 --- a/src/apm_cli/factory.py +++ b/src/apm_cli/factory.py @@ -4,6 +4,7 @@ from .adapters.client.codex import CodexClientAdapter from .adapters.client.copilot import CopilotClientAdapter from .adapters.client.cursor import CursorClientAdapter +from .adapters.client.opencode import OpenCodeClientAdapter from .adapters.package_manager.default_manager import DefaultMCPPackageManager @@ -28,6 +29,7 @@ def create_client(client_type): "vscode": VSCodeClientAdapter, "codex": CodexClientAdapter, "cursor": CursorClientAdapter, + "opencode": OpenCodeClientAdapter, # Add more clients as needed } diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index f909bca1a..a98eafc10 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -413,4 +413,74 @@ def sync_integration_cursor(self, apm_package, project_root: Path, legacy_glob_pattern="*-apm.md", ) + def integrate_package_agents_opencode(self, package_info, project_root: Path, + force: bool = False, + managed_files: set = None, + diagnostics=None) -> IntegrationResult: + """Integrate all agents from a package into .opencode/agents/. + + Only deploys if .opencode/ directory already exists (opt-in). + Uses the same clean filename convention as Claude/Cursor agents. + """ + self.init_link_resolver(package_info, project_root) + + agent_files = self.find_agent_files(package_info.install_path) + if not agent_files: + return IntegrationResult( + files_integrated=0, files_updated=0, + files_skipped=0, target_paths=[], + ) + + opencode_dir = project_root / ".opencode" + if not opencode_dir.exists() or not opencode_dir.is_dir(): + return IntegrationResult( + files_integrated=0, files_updated=0, + files_skipped=0, target_paths=[], + ) + + agents_dir = opencode_dir / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + + files_integrated = 0 + files_skipped = 0 + target_paths = [] + total_links_resolved = 0 + + for source_file in agent_files: + # Reuse Claude naming — plain .md in target dir + target_filename = self.get_target_filename_claude( + source_file, package_info.package.name + ) + target_path = agents_dir / target_filename + rel_path = str(target_path.relative_to(project_root)) + + if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): + files_skipped += 1 + continue + + links_resolved = self.copy_agent(source_file, target_path) + total_links_resolved += links_resolved + files_integrated += 1 + target_paths.append(target_path) + + return IntegrationResult( + files_integrated=files_integrated, + files_updated=0, + files_skipped=files_skipped, + target_paths=target_paths, + links_resolved=total_links_resolved, + ) + + def sync_integration_opencode(self, apm_package, project_root: Path, + managed_files: set = None) -> Dict[str, int]: + """Remove APM-managed agent files from .opencode/agents/.""" + agents_dir = project_root / ".opencode" / "agents" + return self.sync_remove_files( + project_root, + managed_files, + prefix=".opencode/agents/", + legacy_glob_dir=agents_dir, + legacy_glob_pattern="*-apm.md", + ) + diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index 9acffd4dd..a8d5f6f89 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -134,17 +134,19 @@ def partition_managed_files( """Partition *managed_files* by integration prefix in a single pass. Returns a dict with keys ``"prompts"``, ``"agents_github"``, - ``"agents_claude"``, ``"agents_cursor"``, ``"commands"``, - ``"skills"``, ``"hooks"``, ``"instructions"``, - ``"rules_cursor"`` mapping to the subset of paths for each - integration type. + ``"agents_claude"``, ``"agents_cursor"``, ``"agents_opencode"``, + ``"commands"``, ``"commands_opencode"``, ``"skills"``, ``"hooks"``, + ``"instructions"``, ``"rules_cursor"`` mapping to the subset of + paths for each integration type. """ buckets: dict = { "prompts": set(), "agents_github": set(), "agents_claude": set(), "agents_cursor": set(), + "agents_opencode": set(), "commands": set(), + "commands_opencode": set(), "skills": set(), "hooks": set(), "instructions": set(), @@ -159,12 +161,16 @@ def partition_managed_files( buckets["agents_claude"].add(p) elif p.startswith(".cursor/agents/"): buckets["agents_cursor"].add(p) + elif p.startswith(".opencode/agents/"): + buckets["agents_opencode"].add(p) elif p.startswith(".claude/commands/"): buckets["commands"].add(p) - elif p.startswith((".github/skills/", ".claude/skills/", ".cursor/skills/")): + elif p.startswith(".opencode/commands/"): + buckets["commands_opencode"].add(p) + elif p.startswith((".github/skills/", ".claude/skills/", ".cursor/skills/", ".opencode/skills/")): buckets["skills"].add(p) elif p.startswith( - (".github/hooks/", ".claude/hooks/", ".cursor/hooks/") + (".github/hooks/", ".claude/hooks/", ".cursor/hooks/", ".opencode/hooks/") ): buckets["hooks"].add(p) elif p.startswith(".github/instructions/"): diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index 432817849..6cac15c64 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -183,3 +183,76 @@ def remove_package_commands(self, package_name: str, project_root: Path, legacy_glob_pattern="*-apm.md", ) return stats["files_removed"] + + def integrate_package_commands_opencode(self, package_info, project_root: Path, + force: bool = False, + managed_files: set = None, + diagnostics=None) -> IntegrationResult: + """Integrate all prompt files from a package as OpenCode commands. + + Deploys .prompt.md → .opencode/commands/.md. + Only deploys if .opencode/ directory already exists (opt-in). + """ + opencode_dir = project_root / ".opencode" + if not opencode_dir.exists() or not opencode_dir.is_dir(): + return IntegrationResult( + files_integrated=0, files_updated=0, + files_skipped=0, target_paths=[], links_resolved=0, + ) + + commands_dir = opencode_dir / "commands" + prompt_files = self.find_prompt_files(package_info.install_path) + + if not prompt_files: + return IntegrationResult( + files_integrated=0, files_updated=0, + files_skipped=0, target_paths=[], links_resolved=0, + ) + + self.init_link_resolver(package_info, project_root) + + files_integrated = 0 + files_skipped = 0 + target_paths = [] + total_links_resolved = 0 + + for prompt_file in prompt_files: + filename = prompt_file.name + if filename.endswith('.prompt.md'): + base_name = filename[:-len('.prompt.md')] + else: + base_name = prompt_file.stem + + target_path = commands_dir / f"{base_name}.md" + rel_path = str(target_path.relative_to(project_root)) + + if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): + files_skipped += 1 + continue + + links_resolved = self.integrate_command( + prompt_file, target_path, package_info, prompt_file + ) + files_integrated += 1 + total_links_resolved += links_resolved + target_paths.append(target_path) + + return IntegrationResult( + files_integrated=files_integrated, + files_updated=0, + files_skipped=files_skipped, + target_paths=target_paths, + links_resolved=total_links_resolved, + ) + + def sync_integration_opencode(self, apm_package, project_root: Path, + managed_files: set = None) -> Dict: + """Remove APM-managed command files from .opencode/commands/.""" + commands_dir = project_root / ".opencode" / "commands" + return self.sync_remove_files( + project_root, + managed_files, + prefix=".opencode/commands/", + legacy_glob_dir=commands_dir, + legacy_glob_pattern="*-apm.md", + ) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 297d68d92..43e1426df 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -424,7 +424,7 @@ def remove_stale( return # Determine which runtimes to clean, mirroring install-time logic. - all_runtimes = {"vscode", "copilot", "codex", "cursor"} + all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode"} if runtime: target_runtimes = {runtime} else: @@ -542,6 +542,32 @@ def remove_stale( exc_info=True, ) + # Clean opencode.json (only if .opencode/ directory exists) + if "opencode" in target_runtimes: + opencode_cfg = Path.cwd() / "opencode.json" + if opencode_cfg.exists() and (Path.cwd() / ".opencode").is_dir(): + try: + import json as _json + + config = _json.loads(opencode_cfg.read_text(encoding="utf-8")) + servers = config.get("mcp", {}) + removed = [n for n in expanded_stale if n in servers] + for name in removed: + del servers[name] + if removed: + opencode_cfg.write_text( + _json.dumps(config, indent=2), encoding="utf-8" + ) + for name in removed: + _rich_info( + f"+ Removed stale MCP server '{name}' from opencode.json" + ) + except Exception: + logger.debug( + "Failed to clean stale MCP servers from opencode.json", + exc_info=True, + ) + # ------------------------------------------------------------------ # Lockfile persistence # ------------------------------------------------------------------ @@ -633,7 +659,7 @@ def _filter_runtimes(detected_runtimes: List[str]) -> List[str]: except ImportError: mcp_compatible = [ - rt for rt in detected_runtimes if rt in ["vscode", "copilot", "cursor"] + rt for rt in detected_runtimes if rt in ["vscode", "copilot", "cursor", "opencode"] ] return [rt for rt in mcp_compatible if shutil.which(rt)] @@ -691,7 +717,7 @@ def _install_for_runtime( return False except ValueError as e: _rich_warning(f"Runtime {runtime} not supported: {e}") - _rich_info("Supported runtimes: vscode, copilot, codex, cursor, llm") + _rich_info("Supported runtimes: vscode, copilot, codex, cursor, opencode, llm") return False except Exception as e: logger.debug( @@ -804,7 +830,7 @@ def install( manager = RuntimeManager() installed_runtimes = [] - for runtime_name in ["copilot", "codex", "vscode", "cursor"]: + for runtime_name in ["copilot", "codex", "vscode", "cursor", "opencode"]: try: if runtime_name == "vscode": if shutil.which("code") is not None: @@ -815,6 +841,11 @@ def install( if (Path.cwd() / ".cursor").is_dir(): ClientFactory.create_client(runtime_name) installed_runtimes.append(runtime_name) + elif runtime_name == "opencode": + # OpenCode is opt-in: only target when .opencode/ exists + if (Path.cwd() / ".opencode").is_dir(): + ClientFactory.create_client(runtime_name) + installed_runtimes.append(runtime_name) else: if manager.is_runtime_available(runtime_name): ClientFactory.create_client(runtime_name) @@ -830,6 +861,9 @@ def install( # Cursor is directory-presence based, not binary-based if (Path.cwd() / ".cursor").is_dir(): installed_runtimes.append("cursor") + # OpenCode is directory-presence based + if (Path.cwd() / ".opencode").is_dir(): + installed_runtimes.append("opencode") # Step 2: Get runtimes referenced in apm.yml scripts script_runtimes = MCPIntegrator._detect_runtimes( diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index 9394f3a73..cf52665f6 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -312,7 +312,7 @@ def copy_skill_to_target( deployed.append(github_skill_dir) # === Opt-in targets: only deploy when target root already exists === - for target_root in (".claude", ".cursor"): + for target_root in (".claude", ".cursor", ".opencode"): target_dir = target_base / target_root if not (target_dir.exists() and target_dir.is_dir()): continue @@ -620,6 +620,15 @@ def _promote_sub_skills_standalone( ) all_deployed.extend(cursor_deployed) + # Also promote into .opencode/skills/ when .opencode/ exists + opencode_dir = project_root / ".opencode" + if opencode_dir.exists() and opencode_dir.is_dir(): + opencode_skills_root = opencode_dir / "skills" + _, opencode_deployed = self._promote_sub_skills( + sub_skills_dir, opencode_skills_root, parent_name, warn=False, project_root=project_root + ) + all_deployed.extend(opencode_deployed) + return count, all_deployed def _integrate_native_skill( @@ -866,6 +875,7 @@ def sync_integration(self, apm_package, project_root: Path, rel_path.startswith(".github/skills/") or rel_path.startswith(".claude/skills/") or rel_path.startswith(".cursor/skills/") + or rel_path.startswith(".opencode/skills/") ) if not is_skill or ".." in rel_path: continue @@ -922,6 +932,13 @@ def sync_integration(self, apm_package, project_root: Path, stats['files_removed'] += result['files_removed'] stats['errors'] += result['errors'] + # Clean .opencode/skills/ + opencode_skills_dir = project_root / ".opencode" / "skills" + if opencode_skills_dir.exists(): + result = self._clean_orphaned_skills(opencode_skills_dir, installed_skill_names) + stats['files_removed'] += result['files_removed'] + stats['errors'] += result['errors'] + return stats def _clean_orphaned_skills(self, skills_dir: Path, installed_skill_names: set) -> Dict[str, int]: diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 7f295c873..07fea1701 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -133,6 +133,24 @@ def supports(self, primitive: str) -> bool: auto_create=False, detect_by_dir=True, ), + # OpenCode does not support hooks — instructions are via AGENTS.md (apm compile). + "opencode": TargetProfile( + name="opencode", + root_dir=".opencode", + primitives={ + "agents": PrimitiveMapping( + "agents", ".md", "opencode_agent" + ), + "commands": PrimitiveMapping( + "commands", ".md", "opencode_command" + ), + "skills": PrimitiveMapping( + "skills", "/SKILL.md", "skill_standard" + ), + }, + auto_create=False, + detect_by_dir=True, + ), } diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index ca110dd67..544ec014c 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -4,6 +4,7 @@ detect_target, should_integrate_vscode, should_integrate_claude, + should_integrate_opencode, should_compile_agents_md, should_compile_claude_md, get_target_description, @@ -152,7 +153,7 @@ def test_auto_detect_both_folders(self, tmp_path): ) assert target == "all" - assert "both" in reason + assert ".github/" in reason and ".claude/" in reason def test_auto_detect_neither_folder(self, tmp_path): """Auto-detect minimal when neither folder exists.""" @@ -163,7 +164,7 @@ def test_auto_detect_neither_folder(self, tmp_path): ) assert target == "minimal" - assert "no .github/ or .claude/" in reason + assert "no .github/" in reason class TestShouldIntegrateVscode: @@ -277,3 +278,67 @@ def test_minimal_description(self): """Description for minimal target.""" desc = get_target_description("minimal") assert "AGENTS.md only" in desc + + def test_opencode_description(self): + """Description for opencode target.""" + desc = get_target_description("opencode") + assert "AGENTS.md" in desc + assert ".opencode/" in desc + + +class TestShouldIntegrateOpencode: + """Tests for should_integrate_opencode function.""" + + def test_opencode_target(self): + """OpenCode integration enabled for opencode target.""" + assert should_integrate_opencode("opencode") is True + + def test_all_target(self): + """OpenCode integration enabled for all target.""" + assert should_integrate_opencode("all") is True + + def test_vscode_target(self): + """OpenCode integration disabled for vscode target.""" + assert should_integrate_opencode("vscode") is False + + def test_claude_target(self): + """OpenCode integration disabled for claude target.""" + assert should_integrate_opencode("claude") is False + + def test_minimal_target(self): + """OpenCode integration disabled for minimal target.""" + assert should_integrate_opencode("minimal") is False + + +class TestDetectTargetOpencode: + """Tests for auto-detection of OpenCode folders.""" + + def test_auto_detect_opencode_only(self, tmp_path): + """Auto-detect opencode when only .opencode/ exists.""" + (tmp_path / ".opencode").mkdir() + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + assert target == "opencode" + assert ".opencode/" in reason + + def test_auto_detect_opencode_plus_github(self, tmp_path): + """Auto-detect all when .opencode/ and .github/ exist.""" + (tmp_path / ".github").mkdir() + (tmp_path / ".opencode").mkdir() + target, _ = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + assert target == "all" + + def test_opencode_compile_agents_md(self): + """OpenCode target should compile AGENTS.md.""" + assert should_compile_agents_md("opencode") is True + + def test_opencode_no_compile_claude_md(self): + """OpenCode target should NOT compile CLAUDE.md.""" + assert should_compile_claude_md("opencode") is False diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 14faa492a..635446a5b 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -987,3 +987,109 @@ def test_sync_integration_cursor_handles_missing_dir(self): assert result['files_removed'] == 0 assert result['errors'] == 0 + + +class TestOpenCodeAgentIntegration: + """Tests for OpenCode agent integration.""" + + def setup_method(self): + """Set up test fixtures.""" + self.temp_dir = Path(tempfile.mkdtemp()) + self.project_root = self.temp_dir / "project" + self.project_root.mkdir() + self.integrator = AgentIntegrator() + + def teardown_method(self): + """Clean up after tests.""" + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _create_package_info(self, package_dir): + """Helper to create a PackageInfo object.""" + package = APMPackage( + name="test-pkg", + version="1.0.0", + package_path=package_dir + ) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main" + ) + return PackageInfo( + package=package, + install_path=package_dir, + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat() + ) + + def test_integrate_skips_when_opencode_dir_missing(self): + """Opt-in: skip if .opencode/ does not exist.""" + package_dir = self.project_root / "apm_modules" / "test-pkg" + package_dir.mkdir(parents=True) + apm_dir = package_dir / ".apm" / "agents" + apm_dir.mkdir(parents=True) + (apm_dir / "security.agent.md").write_text("# Security Agent") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root + ) + + assert result.files_integrated == 0 + assert not (self.project_root / ".opencode" / "agents").exists() + + def test_integrate_deploys_to_opencode_agents(self): + """Deploy agents to .opencode/agents/ when .opencode/ exists.""" + (self.project_root / ".opencode").mkdir() + package_dir = self.project_root / "apm_modules" / "test-pkg" + package_dir.mkdir(parents=True) + apm_dir = package_dir / ".apm" / "agents" + apm_dir.mkdir(parents=True) + (apm_dir / "security.agent.md").write_text("# Security Agent") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root + ) + + assert result.files_integrated == 1 + assert (self.project_root / ".opencode" / "agents" / "security.md").exists() + + def test_integrate_multiple_agents_opencode(self): + """Deploy multiple agents to .opencode/agents/.""" + (self.project_root / ".opencode").mkdir() + package_dir = self.project_root / "apm_modules" / "test-pkg" + package_dir.mkdir(parents=True) + apm_dir = package_dir / ".apm" / "agents" + apm_dir.mkdir(parents=True) + (apm_dir / "security.agent.md").write_text("# Security") + (apm_dir / "planner.agent.md").write_text("# Planner") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root + ) + + assert result.files_integrated == 2 + + def test_sync_integration_opencode_removes_apm_agents(self): + """Sync removes APM-managed agents from .opencode/agents/.""" + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-apm.md").write_text("# APM managed") + (agents_dir / "custom.md").write_text("# User created") + + result = self.integrator.sync_integration_opencode(None, self.project_root) + + assert result['files_removed'] == 1 + assert not (agents_dir / "security-apm.md").exists() + assert (agents_dir / "custom.md").exists() + + def test_sync_integration_opencode_handles_missing_dir(self): + """Sync handles missing .opencode/agents/ gracefully.""" + result = self.integrator.sync_integration_opencode(None, self.project_root) + + assert result['files_removed'] == 0 + assert result['errors'] == 0 diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index ab202e25b..1c58b9f16 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -266,3 +266,102 @@ def test_claude_metadata_mapping(self, temp_project): assert post.metadata['model'] == 'claude-sonnet' assert post.metadata['argument-hint'] == 'file path' assert 'apm' not in post.metadata + + +class TestOpenCodeCommandIntegration: + """Tests for OpenCode command integration.""" + + @pytest.fixture + def temp_project(self): + """Create a temporary project with .opencode/ directory.""" + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / ".opencode").mkdir() + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + @pytest.fixture + def temp_project_no_opencode(self): + """Create a temporary project without .opencode/ directory.""" + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def _make_package(self, project_root, prompts): + """Create a package with .prompt.md files and return PackageInfo.""" + pkg_dir = project_root / "apm_modules" / "test-pkg" + pkg_dir.mkdir(parents=True) + prompts_dir = pkg_dir / ".apm" / "prompts" + prompts_dir.mkdir(parents=True) + for name, content in prompts.items(): + (prompts_dir / name).write_text(content) + + mock_info = MagicMock() + mock_info.install_path = pkg_dir + mock_info.resolved_reference = None + mock_info.package = MagicMock() + mock_info.package.name = "test-pkg" + return mock_info + + def test_skips_when_opencode_dir_missing(self, temp_project_no_opencode): + """Opt-in: skip if .opencode/ does not exist.""" + pkg_info = self._make_package( + temp_project_no_opencode, + {"test.prompt.md": "---\ndescription: Test\n---\n# Test"}, + ) + integrator = CommandIntegrator() + result = integrator.integrate_package_commands_opencode( + pkg_info, temp_project_no_opencode + ) + assert result.files_integrated == 0 + assert not (temp_project_no_opencode / ".opencode" / "commands").exists() + + def test_deploys_prompts_to_opencode_commands(self, temp_project): + """Deploy .prompt.md → .opencode/commands/.md.""" + pkg_info = self._make_package( + temp_project, + {"test.prompt.md": "---\ndescription: A test\n---\n# Test command"}, + ) + integrator = CommandIntegrator() + result = integrator.integrate_package_commands_opencode( + pkg_info, temp_project + ) + assert result.files_integrated == 1 + target = temp_project / ".opencode" / "commands" / "test.md" + assert target.exists() + + def test_deploys_multiple_prompts(self, temp_project): + """Deploy multiple prompts to .opencode/commands/.""" + pkg_info = self._make_package( + temp_project, + { + "review.prompt.md": "---\ndescription: Review\n---\n# Review", + "fix.prompt.md": "---\ndescription: Fix\n---\n# Fix", + }, + ) + integrator = CommandIntegrator() + result = integrator.integrate_package_commands_opencode( + pkg_info, temp_project + ) + assert result.files_integrated == 2 + + def test_sync_removes_apm_commands(self, temp_project): + """Sync removes APM-managed commands from .opencode/commands/.""" + cmds = temp_project / ".opencode" / "commands" + cmds.mkdir(parents=True) + (cmds / "test-apm.md").write_text("# APM managed") + (cmds / "custom.md").write_text("# User created") + + integrator = CommandIntegrator() + result = integrator.sync_integration_opencode(None, temp_project) + + assert result["files_removed"] == 1 + assert not (cmds / "test-apm.md").exists() + assert (cmds / "custom.md").exists() + + def test_sync_handles_missing_dir(self, temp_project_no_opencode): + """Sync handles missing .opencode/commands/ gracefully.""" + integrator = CommandIntegrator() + result = integrator.sync_integration_opencode(None, temp_project_no_opencode) + assert result["files_removed"] == 0