diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7a888f0..f0508fd13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `apm install --target intellij` now configures JetBrains Copilot MCP support + while routing package file primitives through the Copilot profile. + (by @sergio-sisternes-epam; closes #1957) (#2041) + ## [0.24.1] - 2026-07-10 ### Fixed diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 79497b9f6..6ad5bfa98 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -24,7 +24,7 @@ The full slot-by-slot capability table lives in [Targets matrix](../reference/ta | OpenCode | `.opencode/` | Skills, MCP | | Windsurf | `.windsurf/` | Rules + Skills + Workflows + MCP | | Kiro | `.kiro/` | Steering + Skills + Hooks + MCP | -| JetBrains Copilot | user-scope config dir (global) | MCP only (user-scope path, `${env:VAR}` env substitution) | +| JetBrains Copilot | user-scope config dir (global) | MCP (user-scope path, `${env:VAR}` substitution); file primitives use the Copilot profile | | Agent-Skills (cross) | `.agents/skills/` | Vendor-neutral skill sharing | For exact per-target capabilities (which primitives are supported, transformer used, file layout), see [Targets matrix](../reference/targets-matrix/). @@ -141,19 +141,24 @@ that directory is the auto-detect signal. ```bash # Install an MCP server into the JetBrains user-scope config -apm install --mcp --runtime intellij +apm install --mcp io.github.github/github-mcp-server --target intellij ``` Notes and limits: -- **Auto-detect is user-scope only.** Unlike project markers such as `.cursor/` - or `.windsurf/`, JetBrains is detected from the global config directory, not a - file in your repo. It is therefore detected for every project on the machine - once the plugin directory exists. Use `--runtime intellij` to target it - explicitly regardless of auto-detect. +- **MCP auto-detect is user-scope only.** Unlike project markers such as + `.cursor/` or `.windsurf/`, MCP runtime discovery detects JetBrains from the + global config directory. It is therefore considered for MCP configuration in + every project once the plugin directory exists. This signal does not select a + file-primitive profile; use `--target intellij` explicitly. +- **Composed targets stay exact.** `--target intellij,claude` writes the + JetBrains and Claude MCP configs. `--target all,intellij` adds JetBrains to + the normal `all` target set; plain `all` excludes it. - **Runtime env substitution.** JetBrains Copilot resolves `${env:VAR}` in `mcp.json` at server start. APM preserves env-var placeholders as `${env:VAR}` instead of writing matching host secrets into the config. +- **Policy evaluation.** APM maps `intellij` to `copilot` for organization + allow-lists, so a policy that allows `copilot` also covers IntelliJ installs. ## Per-tool reference pages diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index b6175fe69..af1bb7b4d 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -46,7 +46,7 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA | Flag | Default | Description | |---|---|---| -| `--target`, `-t VALUE` | auto-detect | Force deployment targets. Comma-separated for multiple (`-t claude,cursor`). Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `intellij`, `vscode`, `agent-skills`, `all`; experimental `copilot-cowork` and `copilot-app` are also accepted when enabled. `all` expands to every harness above except `agent-skills` and `antigravity`; combine `all,agent-skills` or `all,antigravity` to add them. Highest precedence in the chain `--target` > `apm.yml targets:` > `apm config set target ...` > auto-detect. With nothing to detect, install exits `2` with a teaching message. | +| `--target`, `-t VALUE` | auto-detect | Force deployment targets. Comma-separated for multiple (`-t claude,cursor`). Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `intellij`, `vscode`, `agent-skills`, `all`; experimental `copilot-cowork` and `copilot-app` are also accepted when enabled. IntelliJ-specific integration is MCP-only and writes JetBrains Copilot's user-scope MCP config; package file primitives use the Copilot profile. `all` excludes `agent-skills`, `antigravity`, and `intellij`; combine them explicitly to add them, for example `all,intellij`. Explicit MCP target lists are exact: `intellij,claude` writes only those two client configs. Highest precedence in the chain `--target` > `apm.yml targets:` > `apm config set target ...` > auto-detect. With nothing to detect, install exits `2` with a teaching message. | | `--runtime VALUE` | unset | Legacy alias for `--target` (single value only). Still accepted; prefer `--target`. | | `--exclude VALUE` | unset | Skip a single runtime that auto-detect or `targets:` would otherwise enable. | | `--only apm\|mcp` | both | Install only APM packages or only MCP servers. | diff --git a/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index e091827f5..65f815700 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -28,12 +28,17 @@ see [Primitive types](./primitive-types/). | opencode | `.opencode/` | [ ] | [ ] | [x] | [x] | [x] | [ ] | [x] | | windsurf | `.windsurf/` + `.agents/` | [x] | [ ] | [ ] | [x] | [x] | [x] | [x] | | kiro | `.kiro/` | [x] | [ ] | [ ] | [x] | [ ] | [x] | [x] | +| intellij | user MCP config; files via Copilot | [x] (*) | [x] (*) | [x] (*) | [x] (*) | [ ] | [x] (*) | [x] | | agent-skills | `.agents/` | [ ] | [ ] | [ ] | [x] | [ ] | [ ] | [ ] | Skills deploy to `.agents/skills/` for Copilot, Cursor, OpenCode, Gemini, Antigravity, Codex, and Windsurf by default (see [Skills convergence](#skills-convergence) below). Claude and Kiro keep target-native skill directories. +(*) For `intellij`, file primitives route through the Copilot profile: +instructions, prompts, agents, and hooks use `.github/`, while skills use +`.agents/skills/`. The IntelliJ-specific adapter configures MCP only. + `copilot-cowork` (Microsoft 365 Copilot), `copilot-app` (GitHub Copilot desktop App), `openclaw` (OpenClaw agent runtime), and `hermes` are gated behind experimental flags and not listed above. See @@ -64,6 +69,12 @@ list before `compile` or `install`. | opencode | `.opencode/` directory | | windsurf | `.windsurf/` directory | | kiro | `.kiro/` directory | +| intellij | Global `github-copilot/intellij/` config directory (MCP runtime discovery only) | + +IntelliJ-specific integration is MCP-only and writes JetBrains Copilot's +user-scope `mcp.json`. That global signal does not auto-select file-primitive +deployment. When `intellij` is selected explicitly, package file primitives use +the Copilot profile. `intellij` does not participate in plain `all` expansion. `agent-skills` is a canonical target key; `antigravity` is explicit-only for auto-detection. Both are available with `--target` and can be listed in a @@ -212,6 +223,23 @@ Kiro IDE. - **MCP shape.** JSON `mcpServers` entries use `command`/`args`/`env` for stdio and `url`/`headers` for remote servers. Kiro resolves `${VAR}` placeholders at runtime, so APM preserves them rather than writing secrets to disk. - **Scope.** This is the documented Kiro IDE layout only. Kiro CLI differences are tracked separately and are not part of this target. +## intellij + +GitHub Copilot for JetBrains IDEs. + +- **Detection.** MCP runtime discovery uses the global + `github-copilot/intellij/` config directory. It does not auto-select a + file-primitive target. +- **Deploy directory.** User-scope `mcp.json`; see the + [JetBrains integration guide](../integrations/ide-tool-integration/#jetbrains-intellij-idea-pycharm-goland-and-others) + for OS-specific paths. +- **Supported primitives.** The IntelliJ-specific adapter supports MCP. + Instructions, prompts, agents, and hooks deploy through the Copilot profile + under `.github/`; skills deploy under `.agents/skills/`. +- **Scope.** MCP configuration is user scope only. File primitives use the + project or user scope selected for the Copilot profile. IntelliJ does not + participate in plain `all` expansion. + ## agent-skills Cross-client shared skills directory. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index cf98f2697..152404620 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -10,7 +10,7 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | +| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | | `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). | | `apm uninstall PKGS...` | Remove packages (accepts `owner/repo` or `name@marketplace`) | `--dry-run`, `-g` global | | `apm prune` | Remove orphaned packages | `--dry-run` | @@ -40,7 +40,12 @@ When a default registry is configured, plain shorthand deps (`owner/repo#`) 1. `--target` flag (highest; CSV form: `--target claude,cursor`). 2. `apm.yml` `targets:` list (or singular `target:` sugar). 3. `apm config set target ` default. -4. Auto-detect from filesystem signals (`.claude/` or `CLAUDE.md` -> claude, `.cursor/` -> cursor, `.github/copilot-instructions.md` or any of `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/` -> copilot, `.codex/` -> codex, `.gemini/` or `GEMINI.md` -> gemini, `.opencode/` -> opencode, `.windsurf/` -> windsurf, `.kiro/` -> kiro, the user-scope JetBrains Copilot MCP config directory `github-copilot/intellij/` -- `%LOCALAPPDATA%\github-copilot\intellij\` on Windows, `~/Library/Application Support/github-copilot/intellij/` on macOS, `~/.local/share/github-copilot/intellij/` on Linux -> intellij). All signals except JetBrains are project-scoped repo markers; the JetBrains signal is a machine-global user-scope directory, so once the Copilot plugin is installed it is detected for every project on that machine. +4. Auto-detect file-primitive targets from project signals (`.claude/` or `CLAUDE.md` -> claude, `.cursor/` -> cursor, `.github/copilot-instructions.md` or any of `.github/instructions/`, `.github/agents/`, `.github/prompts/`, `.github/hooks/` -> copilot, `.codex/` -> codex, `.gemini/` or `GEMINI.md` -> gemini, `.opencode/` -> opencode, `.windsurf/` -> windsurf, `.kiro/` -> kiro). + +MCP runtime discovery separately recognizes the user-scope JetBrains Copilot +config directory (`github-copilot/intellij/`). That machine-global signal can +select IntelliJ for MCP configuration in every project, but it never +auto-selects a file-primitive target. `apm install` prints a one-line provenance summary before any mutation: diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index e90d07c86..503a38a08 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -777,7 +777,7 @@ def _validate_and_add_packages_to_apm_yml( # --------------------------------------------------------------------------- -def _handle_mcp_install( +def _handle_mcp_install( # noqa: PLR0913 *, mcp_name, transport, @@ -789,6 +789,7 @@ def _handle_mcp_install( dev, force, runtime, + target, exclude, verbose, logger, @@ -839,6 +840,9 @@ def _handle_mcp_install( registry=False if _is_self_defined else None, url=url, ) + from ..core.target_detection import normalize_policy_targets + + policy_targets = normalize_policy_targets(target or runtime) try: _pf_result, _pf_active = run_policy_preflight( @@ -847,6 +851,7 @@ def _handle_mcp_install( no_policy=no_policy, logger=logger, dry_run=logger.dry_run, + effective_target=policy_targets, ) except PolicyBlockError: # Diagnostics already emitted by the helper + logger. @@ -878,6 +883,7 @@ def _handle_mcp_install( dev=dev, force=force, runtime=runtime, + target=target, exclude=exclude, logger=logger, apm_dir=mcp_apm_dir, @@ -944,7 +950,7 @@ def _handle_mcp_install( "target", type=TargetParamType(), default=None, - help="Target harness(es) to deploy to. Comma-separated for multiple: --target claude,cursor. Repeating the flag (e.g. '-t a -t b') is NOT supported -- only the last value wins; use commas. Highest-priority entry in the resolution chain (--target > apm.yml targets: > apm config target > auto-detect). Values: copilot, claude, cursor, opencode, codex, gemini, antigravity, windsurf, kiro, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'antigravity' (alias 'agy') deploys to .agents/ (AGENTS.md + rules + skills + hooks.json + mcp_config.json) and is explicit-only -- not part of 'all' or auto-detection. 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro (excludes agent-skills and antigravity); combine with 'agent-skills' or 'antigravity' to add them. 'copilot-cowork' is also accepted when the copilot-cowork experimental flag is enabled (run 'apm experimental enable copilot-cowork'). 'copilot-app' is also accepted when the copilot-app experimental flag is enabled (run 'apm experimental enable copilot-app'). Note: '--target all' on 'apm compile' is deprecated; use 'apm compile --all' instead.", + help="Target harness(es) to deploy to. Use commas for multiple targets; repeating the flag keeps only the last value (use commas instead). Values: copilot, claude, cursor, opencode, codex, gemini, antigravity (agy), windsurf, kiro, intellij, agent-skills, all. IntelliJ-specific integration is MCP-only; file primitives use the Copilot profile. 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro; combine agent-skills, antigravity, or intellij explicitly when needed. Experimental copilot-cowork and copilot-app values require their feature flags. Resolution order: --target > apm.yml targets: > apm config target > auto-detect. With nothing to detect, install exits 2 with a teaching message. For 'apm compile', use '--all'; '--target all' is deprecated.", ) @click.option( "--allow-insecure", @@ -1405,6 +1411,7 @@ def install( # noqa: PLR0913 dev=dev, force=force, runtime=runtime, + target=target, exclude=exclude, verbose=verbose, logger=logger, diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index a9542d5d6..549014b0e 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -126,7 +126,10 @@ def detect_target( # noqa: PLR0911 """ # Priority 1: Explicit --target flag if explicit_target: - if explicit_target in ("copilot", "vscode", "agents"): + if ( + explicit_target in ("copilot", "vscode", "agents") + or explicit_target in MCP_ONLY_TARGETS + ): return "vscode", "explicit --target flag" elif explicit_target == "claude": return "claude", "explicit --target flag" @@ -151,7 +154,7 @@ def detect_target( # noqa: PLR0911 # Priority 2: apm.yml target setting if config_target: - if config_target in ("copilot", "vscode", "agents"): + if config_target in ("copilot", "vscode", "agents") or config_target in MCP_ONLY_TARGETS: return "vscode", "apm.yml target" elif config_target == "claude": return "claude", "apm.yml target" @@ -416,6 +419,13 @@ def get_target_description(target: UserTargetType) -> str: #: so there is no Antigravity-unique signal to auto-detect on. EXPLICIT_ONLY_TARGETS: frozenset[str] = frozenset({"agent-skills", "antigravity"}) +#: MCP-only pseudo-targets that have a client adapter but no +#: ``KNOWN_TARGETS`` entry (they map to a canonical target for primitive +#: deployment via ``RUNTIME_TO_CANONICAL_TARGET``). They must be accepted +#: by ``--target`` so the CLI validates them, but they are excluded from +#: ``"all"`` expansion and do not participate in target-profile machinery. +MCP_ONLY_TARGETS: frozenset[str] = frozenset({"intellij"}) + #: Alias mapping: user-facing name -> canonical internal name. TARGET_ALIASES: dict[str, str] = { "copilot": "vscode", @@ -486,6 +496,31 @@ def normalize_target_list( return result +def normalize_policy_targets(value: str | list[str] | None) -> str | list[str] | None: + """Normalize MCP-only selectors for compilation-target policy checks. + + The return shape matches the input shape so scalar callers remain + backward-compatible while plural target sets are evaluated together. + """ + if value is None: + return None + + from apm_cli.integration.targets import RUNTIME_TO_CANONICAL_TARGET + + values = [value] if isinstance(value, str) else list(value) + normalized: list[str] = [] + for target in values: + if target in MCP_ONLY_TARGETS: + canonical = RUNTIME_TO_CANONICAL_TARGET.get(target) + if canonical is None: + raise RuntimeError(f"MCP-only target '{target}' has no canonical policy mapping") + target = canonical + if target not in normalized: + normalized.append(target) + + return normalized[0] if isinstance(value, str) else normalized + + # --------------------------------------------------------------------------- # Click parameter type for --target (comma-separated multi-target support) # --------------------------------------------------------------------------- @@ -496,6 +531,7 @@ def normalize_target_list( ALL_CANONICAL_TARGETS | EXPERIMENTAL_TARGETS | EXPLICIT_ONLY_TARGETS + | MCP_ONLY_TARGETS | frozenset(TARGET_ALIASES) | frozenset({"all"}) ) @@ -610,7 +646,7 @@ def parse_target_field( # ---- "all" handling ---- if "all" in raw_parts: non_all_tokens = {t for t in raw_parts if t != "all"} - if non_all_tokens - EXPLICIT_ONLY_TARGETS: + if non_all_tokens - EXPLICIT_ONLY_TARGETS - MCP_ONLY_TARGETS: raise ValueError( _target_error( "'all' cannot be combined with other targets", diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index 126efa91e..51184e640 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -56,7 +56,7 @@ class InstallContext: marketplace_provenance: dict[str, Any] | None = None parallel_downloads: int = 4 logger: Any = None # InstallLogger - target_override: str | None = None # effective --target value (CLI or config default) + target_override: str | list[str] | None = None # effective --target value # Provenance label for ``target_override`` when it did NOT come from the CLI. # None means an explicit CLI ``--target`` selector. When the value is # populated from the configured default (``apm config target``), this is diff --git a/src/apm_cli/install/mcp/command.py b/src/apm_cli/install/mcp/command.py index aaf8964a9..5c49175e8 100644 --- a/src/apm_cli/install/mcp/command.py +++ b/src/apm_cli/install/mcp/command.py @@ -35,7 +35,7 @@ pass -def run_mcp_install( +def run_mcp_install( # noqa: PLR0913 *, mcp_name: str, transport: str | None, @@ -51,6 +51,7 @@ def run_mcp_install( logger, apm_dir: Path, scope: str | None, + target: str | list[str] | None = None, registry_url: str | None = None, ) -> None: """Execute the --mcp install path. ``registry_url`` is the validated @@ -114,20 +115,26 @@ def run_mcp_install( # MCPServerOperations() (constructed deep inside MCPIntegrator.install) # picks up the override; prior env restored on exit. if APM_DEPS_AVAILABLE: - if registry_url and logger and verbose: + if registry_url and logger: logger.verbose_detail(f"Registry: {registry_url}") + if target is not None and logger: + rendered_target = target if isinstance(target, str) else ", ".join(target) + logger.verbose_detail(f"Target: {rendered_target}") with registry_env_override(registry_url): try: _mcp_lock_path = get_lockfile_path(apm_dir) _existing_lock = LockFile.read(_mcp_lock_path) old_servers = set(_existing_lock.mcp_servers) if _existing_lock else set() old_configs = dict(_existing_lock.mcp_configs) if _existing_lock else {} + # A scalar target selects the runtime directly; explicit_target + # also preserves the flag for downstream active-target gating. MCPIntegrator.install( [dep], - runtime, + target if isinstance(target, str) else runtime, exclude, verbose, stored_mcp_configs=old_configs, + explicit_target=target, scope=scope, ) new_names = MCPIntegrator.get_server_names([dep]) diff --git a/src/apm_cli/install/phases/policy_target_check.py b/src/apm_cli/install/phases/policy_target_check.py index 524d28b2b..93383cd59 100644 --- a/src/apm_cli/install/phases/policy_target_check.py +++ b/src/apm_cli/install/phases/policy_target_check.py @@ -60,6 +60,16 @@ def run(ctx: InstallContext) -> None: if effective_target is None: return # no target to check -- trivially passes + # ------------------------------------------------------------------ + # 3b. Normalize scalar or plural MCP-only pseudo-targets to their + # canonical policy form (e.g. intellij -> copilot). + # ------------------------------------------------------------------ + # Keep these lazy: target_detection and integration.targets reference each + # other at runtime, so module-level imports would create an import cycle. + from apm_cli.core.target_detection import normalize_policy_targets + + effective_target = normalize_policy_targets(effective_target) + # ------------------------------------------------------------------ # 4. Run policy checks with effective_target populated # ------------------------------------------------------------------ diff --git a/src/apm_cli/install/phases/targets.py b/src/apm_cli/install/phases/targets.py index 0d3940e9e..0a243c951 100644 --- a/src/apm_cli/install/phases/targets.py +++ b/src/apm_cli/install/phases/targets.py @@ -526,8 +526,8 @@ def run(ctx: InstallContext) -> None: # the pre-refactor mega-function. detect_target( project_root=ctx.project_root, - explicit_target=_explicit, - config_target=config_target, + explicit_target=_explicit if isinstance(_explicit, str) else None, + config_target=config_target if isinstance(config_target, str) else None, ) # ------------------------------------------------------------------ diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index dad2673d4..9f2323147 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -1210,7 +1210,7 @@ def install( stored_mcp_configs: dict = None, # noqa: RUF013 project_root=None, user_scope: bool = False, - explicit_target: str | None = None, + explicit_target: str | list[str] | None = None, logger=None, diagnostics=None, scope=None, diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index 2f252376e..3016e7cf6 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -330,7 +330,7 @@ def _resolve_target_runtimes( apm_config: dict | None, project_root, user_scope: bool, - explicit_target: str | None, + explicit_target: str | list[str] | None, scope: InstallScope | None, logger, console, @@ -347,6 +347,15 @@ def _resolve_target_runtimes( # Single runtime mode - skip auto-discovery entirely. logger.progress(f"Targeting specific runtime: {runtime}") target_runtimes: list[str] = [runtime] + elif explicit_target is not None: + # A plural --target value is already parser-normalized. Use that exact + # runtime set instead of broad discovery so selecting IntelliJ does not + # also select adjacent runtimes that share its Copilot policy profile. + target_runtimes = ( + [explicit_target] if isinstance(explicit_target, str) else list(explicit_target) + ) + runtime_label = "runtime" if len(target_runtimes) == 1 else "runtimes" + logger.progress(f"Targeting specific {runtime_label}: {', '.join(target_runtimes)}") else: project_root_path = Path(project_root) if project_root is not None else Path.cwd() @@ -631,7 +640,7 @@ def run_mcp_install( stored_mcp_configs: dict | None = None, project_root=None, user_scope: bool = False, - explicit_target: str | None = None, + explicit_target: str | list[str] | None = None, logger=None, diagnostics=None, scope: InstallScope | None = None, diff --git a/src/apm_cli/policy/install_preflight.py b/src/apm_cli/policy/install_preflight.py index 58d13b0fc..fb06db679 100644 --- a/src/apm_cli/policy/install_preflight.py +++ b/src/apm_cli/policy/install_preflight.py @@ -69,6 +69,7 @@ def run_policy_preflight( logger, dry_run: bool = False, registries: dict[str, str] | None = None, + effective_target: str | list[str] | None = None, ) -> tuple[PolicyFetchResult | None, bool]: """Discover + enforce policy for a non-pipeline command site. @@ -81,6 +82,8 @@ def run_policy_preflight( dep checks. mcp_deps: Iterable of ``MCPDependency``, or ``None`` to skip MCP checks. + effective_target: + Resolved scalar or plural target selection for compilation policy. no_policy: CLI ``--no-policy`` flag value. logger: @@ -150,6 +153,7 @@ def run_policy_preflight( lockfile=None, policy=policy, mcp_deps=mcp_deps, + effective_target=effective_target, fail_fast=(enforcement == "block"), registries=registries, direct_dep_keys={d.get_unique_key() for d in apm_deps_list}, diff --git a/src/apm_cli/policy/policy_checks.py b/src/apm_cli/policy/policy_checks.py index 4227be0ce..1e1910fa4 100644 --- a/src/apm_cli/policy/policy_checks.py +++ b/src/apm_cli/policy/policy_checks.py @@ -1101,7 +1101,7 @@ def run_dependency_policy_checks( lockfile=None, policy: ApmPolicy, mcp_deps=None, - effective_target: str | None = None, + effective_target: str | list[str] | None = None, fetch_outcome: str | None = None, fail_fast: bool = True, manifest_includes=_INCLUDES_NOT_PROVIDED, diff --git a/tests/integration/test_intellij_target.py b/tests/integration/test_intellij_target.py new file mode 100644 index 000000000..77675b453 --- /dev/null +++ b/tests/integration/test_intellij_target.py @@ -0,0 +1,266 @@ +"""Integration tests for the 'intellij' MCP-only pseudo-target. + +Covers: + 1. Parser-layer constants: intellij in VALID_TARGET_VALUES / MCP_ONLY_TARGETS, + not in ALL_CANONICAL_TARGETS. + 2. TargetParamType accepts intellij as single and multi-target. + 3. CLI parser accepts ``--target intellij`` without error. + 4. Policy target check normalises intellij -> copilot via + RUNTIME_TO_CANONICAL_TARGET so org allow-lists are not falsely rejected. + +IntelliJ is an MCP-only pseudo-target: it has an IntelliJClientAdapter +registered in the MCP client registry but no KNOWN_TARGETS entry and no +file-level primitive deployment. It maps to 'copilot' via +RUNTIME_TO_CANONICAL_TARGET. + +Ref: issue #1957, PR #2041. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli +from apm_cli.core.target_detection import ( + ALL_CANONICAL_TARGETS, + MCP_ONLY_TARGETS, + VALID_TARGET_VALUES, + TargetParamType, + normalize_target_list, +) +from apm_cli.integration.targets import RUNTIME_TO_CANONICAL_TARGET + +_MINIMAL_APM_YML = "name: test\ndescription: test\nversion: 0.0.1\n" +_BASE_ENV: dict[str, str] = {"APM_E2E_TESTS": "1"} + + +@pytest.fixture() +def apm_command() -> str: + """Return the APM executable used by the end-to-end test.""" + executable_name = "apm.exe" if sys.platform == "win32" else "apm" + venv_apm = ( + Path(__file__).parents[2] + / ".venv" + / ("Scripts" if sys.platform == "win32" else "bin") + / executable_name + ) + if venv_apm.exists(): + return str(venv_apm) + apm_on_path = shutil.which("apm") + if apm_on_path: + return apm_on_path + pytest.fail("APM executable not found in the project virtualenv or PATH") + + +@pytest.fixture() +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Isolated home directory wired into every APM config lookup.""" + home = tmp_path / "home" + apm_dir = home / ".apm" + apm_dir.mkdir(parents=True) + (apm_dir / "apm.yml").write_text(_MINIMAL_APM_YML, encoding="ascii") + + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + + import apm_cli.config as _conf + + monkeypatch.setattr(_conf, "CONFIG_DIR", str(apm_dir)) + monkeypatch.setattr(_conf, "CONFIG_FILE", str(apm_dir / "config.json")) + monkeypatch.setattr(_conf, "_config_cache", None) + yield home + monkeypatch.setattr(_conf, "_config_cache", None) + + +# =========================================================================== +# Constant guards +# =========================================================================== + + +class TestIntelliJConstants: + """Constant-split guards ensuring intellij stays MCP-only.""" + + def test_intellij_in_valid_target_values(self) -> None: + """intellij must be accepted by --target.""" + assert "intellij" in VALID_TARGET_VALUES + + def test_intellij_not_in_all_canonical_targets(self) -> None: + """intellij must NOT bleed into ALL_CANONICAL_TARGETS / 'all'.""" + assert "intellij" not in ALL_CANONICAL_TARGETS + + def test_intellij_in_mcp_only_targets(self) -> None: + """intellij must live in MCP_ONLY_TARGETS.""" + assert "intellij" in MCP_ONLY_TARGETS + + def test_intellij_runtime_canonical_maps_to_copilot(self) -> None: + """RUNTIME_TO_CANONICAL_TARGET must map intellij -> copilot.""" + assert RUNTIME_TO_CANONICAL_TARGET.get("intellij") == "copilot" + + def test_all_mcp_only_targets_have_canonical_mapping(self) -> None: + """Every MCP-only target must have a fail-closed policy mapping.""" + missing = MCP_ONLY_TARGETS - RUNTIME_TO_CANONICAL_TARGET.keys() + assert not missing, f"MCP-only targets lack canonical mappings: {missing}" + + def test_parser_accepts_single(self) -> None: + """TargetParamType.convert accepts 'intellij' as a single token.""" + tp = TargetParamType() + result = tp.convert("intellij", None, None) + assert result == "intellij" + + def test_parser_accepts_multi(self) -> None: + """TargetParamType.convert accepts 'intellij,claude' as multi-target.""" + tp = TargetParamType() + result = tp.convert("intellij,claude", None, None) + assert isinstance(result, list) + assert "intellij" in result + assert "claude" in result + + def test_all_expansion_excludes_intellij(self) -> None: + """normalize_target_list('all') must NOT include intellij.""" + result = normalize_target_list("all") + assert "intellij" not in result + + +# =========================================================================== +# CLI E2E +# =========================================================================== + + +class TestIntelliJCliE2E: + """CliRunner tests for 'apm install --target intellij'.""" + + def test_cli_parser_accepts_target_intellij(self, tmp_path: Path, fake_home: Path) -> None: + """``apm install --target intellij`` must not fail with 'Unknown target'. + + We do not assert a successful install (no package is provided), + just that the CLI parser accepts the target token without error. + """ + runner = CliRunner() + result = runner.invoke( + cli, + ["install", "--target", "intellij"], + env=_BASE_ENV, + catch_exceptions=False, + ) + # No "Unknown target" error -- the parser accepted the value. + # The command will fail for other reasons (no package specified) + # but that is fine -- we only care about parser acceptance. + assert "Unknown target" not in (result.output or "") + + @pytest.mark.requires_apm_binary + @pytest.mark.parametrize( + ("target", "expect_claude"), + [ + ("intellij", False), + ("intellij,claude", True), + ("all,intellij", True), + ], + ) + def test_mcp_install_respects_composed_target_set( + self, + tmp_path: Path, + apm_command: str, + target: str, + expect_claude: bool, + ) -> None: + """The real CLI writes only configs selected by the target set.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".claude").mkdir() + (project_dir / "apm.yml").write_text(_MINIMAL_APM_YML, encoding="ascii") + fake_home = tmp_path / "home" + fake_home.mkdir() + xdg_data = fake_home / "xdg" + local_app_data = fake_home / "local-app-data" + + env = os.environ.copy() + env.update( + { + "HOME": str(fake_home), + "XDG_DATA_HOME": str(xdg_data), + "LOCALAPPDATA": str(local_app_data), + "GIT_TERMINAL_PROMPT": "0", + "APM_NON_INTERACTIVE": "1", + } + ) + + result = subprocess.run( + [ + apm_command, + "install", + "--mcp", + "test-http-server", + "--target", + target, + "--transport", + "http", + "--url", + "https://example.com/mcp", + ], + cwd=project_dir, + capture_output=True, + text=True, + timeout=120, + env=env, + ) + + assert result.returncode == 0, ( + f"apm install failed (rc={result.returncode}).\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + if sys.platform == "win32": + config_path = local_app_data / "github-copilot" / "intellij" / "mcp.json" + elif sys.platform == "darwin": + config_path = ( + fake_home + / "Library" + / "Application Support" + / "github-copilot" + / "intellij" + / "mcp.json" + ) + else: + config_path = xdg_data / "github-copilot" / "intellij" / "mcp.json" + + assert config_path.exists(), ( + f"Expected IntelliJ MCP config at {config_path}.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + config = json.loads(config_path.read_text(encoding="utf-8")) + assert config["servers"]["test-http-server"]["url"] == "https://example.com/mcp" + + claude_config = project_dir / ".mcp.json" + assert claude_config.exists() is expect_claude + if expect_claude: + claude = json.loads(claude_config.read_text(encoding="utf-8")) + assert claude["mcpServers"]["test-http-server"]["url"] == "https://example.com/mcp" + + if target == "intellij": + assert not (project_dir / ".vscode" / "mcp.json").exists() + + +# =========================================================================== +# Policy target normalisation +# =========================================================================== + + +class TestIntelliJPolicyNormalisation: + """Verify policy_target_check normalises intellij -> copilot.""" + + def test_runtime_to_canonical_normalisation(self) -> None: + """The RUNTIME_TO_CANONICAL_TARGET mapping must resolve intellij. + + This is the mapping used in policy_target_check.py to normalise + the effective_target before passing it to _check_compilation_target. + An org with ``allow: [copilot]`` must not reject --target intellij. + """ + effective_target = "intellij" + normalised = RUNTIME_TO_CANONICAL_TARGET.get(effective_target, effective_target) + assert normalised == "copilot" diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index eda600f7b..f7a903835 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -5,15 +5,18 @@ import click import pytest +from apm_cli.core import target_detection from apm_cli.core.target_detection import ( ALL_CANONICAL_TARGETS, EXPERIMENTAL_TARGETS, + MCP_ONLY_TARGETS, VALID_TARGET_VALUES, TargetParamType, can_dedup_agents_md_instructions, detect_target, get_dedup_rules_dir, get_target_description, + normalize_policy_targets, normalize_target_list, should_compile_agents_md, should_compile_claude_md, @@ -60,6 +63,16 @@ def test_explicit_target_agents_maps_to_vscode(self, tmp_path): assert target == "vscode" assert reason == "explicit --target flag" + def test_explicit_target_intellij_maps_to_vscode(self, tmp_path): + """Explicit --target intellij maps to vscode (MCP-only target, #1957).""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target="intellij", + ) + + assert target == "vscode" + assert reason == "explicit --target flag" + def test_explicit_target_claude_wins(self, tmp_path): """Explicit --target claude always wins.""" (tmp_path / ".github").mkdir() @@ -114,6 +127,17 @@ def test_config_target_vscode(self, tmp_path): assert target == "vscode" assert reason == "apm.yml target" + def test_config_target_intellij(self, tmp_path): + """Config target intellij maps to the Copilot deployment profile.""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target="intellij", + ) + + assert target == "vscode" + assert reason == "apm.yml target" + def test_config_target_claude(self, tmp_path): """Config target claude is used when no explicit target.""" target, reason = detect_target( @@ -556,6 +580,10 @@ def test_valid_target_values_includes_all(self): """VALID_TARGET_VALUES contains 'all'.""" assert "all" in VALID_TARGET_VALUES + def test_valid_target_values_includes_intellij(self): + """VALID_TARGET_VALUES contains 'intellij' (MCP-only target, #1957).""" + assert "intellij" in VALID_TARGET_VALUES + # -- None passthrough ------------------------------------------------- def test_none_returns_none(self): @@ -608,6 +636,10 @@ def test_single_all(self): """'all' returns string 'all' for backward compat.""" assert self.tp.convert("all", None, None) == "all" + def test_single_intellij(self): + """intellij is accepted as a valid MCP-only target (#1957).""" + assert self.tp.convert("intellij", None, None) == "intellij" + def test_single_target_returns_string_type(self): """Single target must return str, not list.""" result = self.tp.convert("claude", None, None) @@ -646,6 +678,11 @@ def test_multi_three_targets(self): result = self.tp.convert("claude,cursor,codex", None, None) assert result == ["claude", "cursor", "codex"] + def test_multi_intellij_with_claude(self): + """intellij,claude keeps intellij as-is in multi-target (#1957).""" + result = self.tp.convert("intellij,claude", None, None) + assert result == ["intellij", "claude"] + # -- Alias deduplication ---------------------------------------------- def test_copilot_vscode_deduplicates(self): @@ -869,6 +906,82 @@ def test_all_combined_with_codex_still_rejected(self): with pytest.raises(click.exceptions.BadParameter, match="cannot be combined"): self.tp.convert("all,codex", None, None) + def test_all_combined_with_intellij_allowed(self): + """'all,intellij' is allowed -- intellij is an MCP-only target (#1957).""" + from apm_cli.core.target_detection import parse_target_field + + result = parse_target_field("all,intellij") + assert isinstance(result, list) + for t in ALL_CANONICAL_TARGETS: + assert t in result + assert "intellij" in result + + +class TestIntelliJConstantGuards: + """Constant-split guards ensuring intellij stays MCP-only (#1957). + + IntelliJ is an MCP-only pseudo-target -- it must be in MCP_ONLY_TARGETS + and VALID_TARGET_VALUES, but NOT in ALL_CANONICAL_TARGETS. The 'all' + expansion must never include it. + """ + + def test_intellij_not_in_all_canonical_targets(self): + """'intellij' must NOT appear in ALL_CANONICAL_TARGETS. + + ALL_CANONICAL_TARGETS drives the 'all' expansion. IntelliJ is + MCP-only and must live in MCP_ONLY_TARGETS instead. + """ + assert "intellij" not in ALL_CANONICAL_TARGETS + + def test_intellij_in_mcp_only_targets(self): + """'intellij' must appear in MCP_ONLY_TARGETS (constant guard).""" + assert "intellij" in MCP_ONLY_TARGETS + + def test_all_expansion_excludes_intellij(self): + """normalize_target_list('all') must NOT include 'intellij'. + + 'all' expands only to ALL_CANONICAL_TARGETS. MCP-only targets + require explicit opt-in via '--target intellij' or 'all,intellij'. + """ + result = normalize_target_list("all") + assert isinstance(result, list) + assert "intellij" not in result + # Verify all canonical targets ARE present + for t in ALL_CANONICAL_TARGETS: + assert t in result + + +class TestNormalizePolicyTargets: + """Direct contract tests for MCP-only policy target normalization.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("intellij", "copilot"), + (["intellij", "claude"], ["copilot", "claude"]), + (["intellij", "copilot"], ["copilot"]), + ], + ) + def test_normalizes_shape_and_deduplicates( + self, + value: str | list[str] | None, + expected: str | list[str] | None, + ) -> None: + """Normalize MCP-only values while preserving scalar/list shape.""" + assert normalize_policy_targets(value) == expected + + def test_unmapped_mcp_only_target_fails_closed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Reject an MCP-only target without a canonical policy mapping.""" + monkeypatch.setattr( + target_detection, + "MCP_ONLY_TARGETS", + frozenset({*MCP_ONLY_TARGETS, "unmapped-mcp"}), + ) + + with pytest.raises(RuntimeError, match="has no canonical policy mapping"): + normalize_policy_targets("unmapped-mcp") + # --------------------------------------------------------------------------- # Cowork parser-layer regression tests (2f96dd5 / #926) diff --git a/tests/unit/install/test_mcp_preflight_policy.py b/tests/unit/install/test_mcp_preflight_policy.py index 4d8e29db1..1212f7f8d 100644 --- a/tests/unit/install/test_mcp_preflight_policy.py +++ b/tests/unit/install/test_mcp_preflight_policy.py @@ -31,6 +31,8 @@ from apm_cli.policy.parser import load_policy from apm_cli.policy.schema import ( ApmPolicy, + CompilationPolicy, + CompilationTargetPolicy, DependencyPolicy, # noqa: F401 McpPolicy, McpTransportPolicy, # noqa: F401 @@ -138,7 +140,7 @@ def test_env_disable_zero_does_not_skip(self, monkeypatch): # the dep is denied under block enforcement. with pytest.raises(PolicyBlockError): run_policy_preflight( - project_root=Path("/tmp/fake"), + project_root=Path("fake"), mcp_deps=[_make_mcp_dep("io.github.untrusted/evil-mcp")], no_policy=False, logger=logger, @@ -166,6 +168,24 @@ def test_allowed_mcp_under_block_proceeds(self): assert result is not None assert active is True + def test_direct_mcp_plural_targets_are_policy_checked(self): + """Direct --mcp applies compilation policy to the whole target set.""" + policy = ApmPolicy( + enforcement="block", + compilation=CompilationPolicy( + target=CompilationTargetPolicy(allow=("copilot",)), + ), + ) + fetch = _make_fetch_result(policy=policy) + + with _patch_discover(fetch), pytest.raises(PolicyBlockError): + run_policy_preflight( + project_root=Path("/tmp/fake"), + mcp_deps=[_make_mcp_dep("test-server", transport="http")], + effective_target=["copilot", "claude"], + logger=_make_logger(), + ) + def test_allowed_mcp_under_warn_proceeds(self): """Allowed MCP proceeds under warn enforcement too.""" policy, _ = load_policy(MCP_POLICY_FIXTURE) diff --git a/tests/unit/install/test_policy_target_check_phase.py b/tests/unit/install/test_policy_target_check_phase.py index ac460d030..9e9654b2b 100644 --- a/tests/unit/install/test_policy_target_check_phase.py +++ b/tests/unit/install/test_policy_target_check_phase.py @@ -73,7 +73,7 @@ class _FakeContext: # From caller / CLI apm_package: Any = None - target_override: str | None = None + target_override: str | list[str] | None = None # From policy_gate policy_fetch: Any = None @@ -337,6 +337,53 @@ def test_cli_override_fixes_disallowed_manifest_target(self, mock_checks): ctx.logger.policy_violation.assert_not_called() + @patch(_PATCH_CHECKS) + def test_intellij_override_is_normalized_to_copilot(self, mock_checks): + """MCP-only IntelliJ uses its canonical policy target.""" + mock_checks.return_value = _target_passing_audit() + ctx = _make_ctx( + enforcement_active=True, + policy_fetch=_make_policy_fetch(enforcement="block", allow=("copilot",)), + target_override="intellij", + ) + + run(ctx) + + mock_checks.assert_called_once() + assert mock_checks.call_args.kwargs["effective_target"] == "copilot" + ctx.logger.policy_violation.assert_not_called() + + @patch(_PATCH_CHECKS) + def test_plural_override_is_normalized_as_one_target_set(self, mock_checks): + """Plural target policy checks preserve every selected target.""" + mock_checks.return_value = _target_passing_audit() + ctx = _make_ctx( + enforcement_active=True, + policy_fetch=_make_policy_fetch( + enforcement="block", + allow=("copilot", "claude"), + ), + target_override=["intellij", "claude"], + ) + + run(ctx) + + mock_checks.assert_called_once() + assert mock_checks.call_args.kwargs["effective_target"] == ["copilot", "claude"] + + def test_plural_override_blocks_when_any_target_is_disallowed(self): + """A plural target set cannot bypass compilation.target.allow.""" + ctx = _make_ctx( + enforcement_active=True, + policy_fetch=_make_policy_fetch(enforcement="block", allow=("copilot",)), + target_override=["intellij", "claude"], + ) + + with pytest.raises(PolicyViolationError, match="compilation target"): + run(ctx) + + ctx.logger.policy_violation.assert_called_once() + # ===================================================================== # Test: double-emit filtering