Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@ apm_modules/
build/tmp/
scout-pipeline-result.png
.copilot/
.cursor/
.playwright-mcp/
server.pid
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- **Manifest contract: invalid `target:` values now raise a parse error.** Previously, an unknown token (or a CSV string like `target: opencode,claude,copilot,agents` instead of the YAML list `target: [opencode, claude, copilot, agents]`) was silently ignored, leaving `apm install` and `apm compile` to exit 0 while deploying nothing. The shared parser used by `--target` now also validates `apm.yml`'s `target:`, so the same input resolves the same way at every entry point. **Migration:** three previously-silent inputs now fail loud -- (1) unknown tokens (`target: bogus` -> fix the typo), (2) empty values (`target: ""`, `target: []` -> remove the line if you meant auto-detect), (3) `all` mixed with other targets (`target: [all, claude]` -> use `all` alone). Omitting `target:` entirely still triggers auto-detection. (#820)
- Cursor adapter: refactor `_format_server_config` from a ~100-line copy-paste fork to a `super()` + transform pattern (~15 lines). This restores header env-var resolution, `_warn_input_variables`, and `_apm_tools_override` that were silently dropped in the initial Cursor integration. For security, resolved Bearer tokens are replaced with `${env:GITHUB_TOKEN}` references so credentials never touch the repo-local `.cursor/mcp.json` file. Fixes #844.
- Cursor adapter: update module and class docstrings to document the delegate-then-transform pattern (matching `OpenCodeClientAdapter`), and replace non-ASCII em dashes with ASCII.
- Policy parser: catch `ValueError` alongside `OSError` in `load_policy()` `Path.is_file()` guard to tolerate invalid path strings (e.g. embedded NUL bytes) without crashing. Closes #848.
- Rename `DownloadStrategyManager` to `DownloadDelegate` to better reflect Facade/Delegate pattern (#918)
- Fix incorrect double-checked locking in marketplace registry `_load()` -- hold lock across full check+read+set (#918)

### Fixed

- `apm install` and `apm compile` no longer exit 0 with success messages when `target:` in `apm.yml` is a CSV string -- the value now parses identically to the same input on `--target`, and zero-target resolution surfaces a warning instead of a silent no-op. (#820)
- Remove redundant `seen` set from `_scan_patterns()` discovery walk (#918)
- CI docs: clarify that branch-protection ruleset must store the check-run name (`gate`), not the workflow display string (`Merge Gate / gate`); document the merge-gate aggregator in `cicd.instructions.md` and mark the legacy stub workflow as deprecated.
- `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated so private repos work without separate credential setup. (#1008)
- `apm marketplace build` now accepts multiple Git URL forms (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) for `type: url` parsing via `DependencyReference.parse()`. Host resolution is still driven by `GITHUB_HOST`, so non-`github.com` hosts require `GITHUB_HOST` to be set accordingly. (#1008)
- **ADO Entra ID auth path no longer silently fails.** Bearer tokens from `az account get-access-token` are now correctly plumbed through validation (auth scheme, git env). Auth failures raise a typed `AuthenticationError` with an actionable 4-case diagnostic instead of the ambiguous "not accessible or doesn't exist" message. `apm install --update` runs a pre-flight auth check before modifying any files -- on failure it aborts with "No files were modified". (#1015)
Expand Down
5 changes: 3 additions & 2 deletions src/apm_cli/adapters/client/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class CopilotClientAdapter(MCPClientAdapter):
MCP server configuration.
"""
supports_user_scope: bool = True
_runtime_label: str = "Copilot CLI"

def __init__(
self,
Expand Down Expand Up @@ -185,7 +186,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
config["args"] = raw["args"]
if raw.get("env"):
config["env"] = raw["env"]
self._warn_input_variables(raw["env"], server_info.get("name", ""), "Copilot CLI")
self._warn_input_variables(raw["env"], server_info.get("name", ""), self._runtime_label)
# Apply tools override if present
tools_override = server_info.get("_apm_tools_override")
if tools_override:
Expand Down Expand Up @@ -254,7 +255,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No

# Warn about unresolvable ${input:...} references in headers
if config.get("headers"):
self._warn_input_variables(config["headers"], server_info.get("name", ""), "Copilot CLI")
self._warn_input_variables(config["headers"], server_info.get("name", ""), self._runtime_label)

# Apply tools override from MCP dependency overlay if present
tools_override = server_info.get("_apm_tools_override")
Expand Down
101 changes: 87 additions & 14 deletions src/apm_cli/adapters/client/cursor.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,44 @@
"""Cursor IDE implementation of MCP client adapter.

Cursor uses the standard ``mcpServers`` JSON format at ``.cursor/mcp.json``
(repo-local). The config schema is identical to GitHub Copilot CLI, so this
adapter subclasses :class:`CopilotClientAdapter` and only overrides the
config-path logic and the user-facing labels.
(repo-local). Cursor's schema differs from Copilot CLI in key ways:

- ``type`` must be ``"stdio"`` or ``"http"`` (not ``"local"``).
- ``tools`` and ``id`` fields are not supported.

This adapter delegates config formatting to :class:`CopilotClientAdapter`
and then transforms the result for Cursor compatibility.

APM only writes to ``.cursor/mcp.json`` when the ``.cursor/`` directory
already exists Cursor support is opt-in.
already exists -- Cursor support is opt-in.
"""

import json
import os
from pathlib import Path
from typing import Optional

from .copilot import CopilotClientAdapter


class CursorClientAdapter(CopilotClientAdapter):
"""Cursor IDE MCP client adapter.

Inherits all config formatting from :class:`CopilotClientAdapter`
(``mcpServers`` JSON with ``command``/``args``/``env``). Only the
config-file location differs: repo-local ``.cursor/mcp.json`` instead
of global ``~/.copilot/mcp-config.json``.
Inherits config path and read/write logic from :class:`CopilotClientAdapter`
and uses the delegate-then-transform pattern for ``_format_server_config``:

- Calls ``super()._format_server_config()`` to get the fully-resolved config
(GitHub auth, header env-var resolution, ``_warn_input_variables``, etc.)
- Translates Cursor-incompatible fields:
- ``type: "local"`` becomes ``"stdio"`` (Cursor schema)
- Copilot-specific ``tools`` and ``id`` fields stripped
- Security: resolved Bearer tokens and env-var secrets are replaced with
``${env:...}`` references so credentials never touch the repo-local
``.cursor/mcp.json`` file.
"""

supports_user_scope: bool = False
_runtime_label: str = "Cursor"

# ------------------------------------------------------------------ #
# Config path
Expand All @@ -35,14 +48,14 @@ def get_config_path(self):
"""Return the path to ``.cursor/mcp.json`` in the repository root.

Unlike the Copilot adapter this is a **repo-local** path. The
``.cursor/`` directory is *not* created automatically APM only
``.cursor/`` directory is *not* created automatically -- APM only
writes here when the directory already exists.
"""
cursor_dir = self.project_root / ".cursor"
return str(cursor_dir / "mcp.json")

# ------------------------------------------------------------------ #
# Config read / write override to avoid auto-creating the directory
# Config read / write -- override to avoid auto-creating the directory
# ------------------------------------------------------------------ #

def update_config(self, config_updates):
Expand Down Expand Up @@ -80,7 +93,7 @@ def get_current_config(self):
return {}

# ------------------------------------------------------------------ #
# configure_mcp_server thin override for the print label
# configure_mcp_server -- thin override for the print label
# ------------------------------------------------------------------ #

def configure_mcp_server(
Expand Down Expand Up @@ -130,11 +143,71 @@ def configure_mcp_server(
)
self.update_config({config_key: server_config})

print(
f"Successfully configured MCP server '{config_key}' for Cursor"
)
print(f"Successfully configured MCP server '{config_key}' for Cursor")
return True

except Exception as e:
print(f"Error configuring MCP server: {e}")
return False

# ------------------------------------------------------------------ #
# _format_server_config -- delegate to parent, then transform for Cursor
# ------------------------------------------------------------------ #
Comment on lines +153 to +155
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

This comment header uses a Unicode em dash character (�> ASCII). The repo's encoding rule requires source files to be printable ASCII only; please replace it with ASCII punctuation (e.g., "--").

Copilot generated this review using guidance from repository custom instructions.

def _format_server_config(
self,
server_info: dict,
env_overrides: Optional[dict] = None,
runtime_vars: Optional[dict] = None,
) -> dict:
"""Format server config via parent, then adapt for Cursor schema.

Cursor requires ``type: "stdio"`` (not ``"local"``) and does not
support the Copilot-specific ``tools`` and ``id`` fields.

Security: ``.cursor/mcp.json`` is repo-local and may be committed to
version control. For GitHub MCP servers the parent injects a literal
``Bearer <token>`` header; this override replaces it with a
``${env:GITHUB_TOKEN}`` reference so the secret never touches disk.
Cursor resolves ``${env:...}`` from the process environment at runtime.
"""
config = super()._format_server_config(server_info, env_overrides, runtime_vars)
config.pop("tools", None)
config.pop("id", None)
if config.get("type") == "local":
config["type"] = "stdio"

# Security: .cursor/mcp.json is repo-local and may be committed to
# version control. Any header value that the parent resolved from
# an environment variable placeholder (<VAR>) must NOT be written
# as plaintext. Replace resolved Bearer tokens with env-var
# references and strip other resolved secrets entirely.
_headers = config.get("headers", {})
if _headers:
remote = (server_info.get("remotes") or [{}])[0]
_is_gh = self._is_github_server(
server_info.get("name", ""), remote.get("url", "")
)
_keys_to_strip = []
for _hname, _hval in _headers.items():
if _is_gh and _hname == "Authorization":
_auth = str(_hval)
if _auth.startswith("Bearer ") and not _auth.startswith(
"Bearer ${"
):
_headers[_hname] = "Bearer ${env:GITHUB_TOKEN}"
elif isinstance(_hval, str) and not _hval.startswith("${"):
_raw_headers = remote.get("headers", []) if remote else []
_was_placeholder = any(
h.get("name") == _hname
and ("<" in h.get("value", "") or "${" in h.get("value", ""))
for h in _raw_headers
)
if _was_placeholder:
_keys_to_strip.append(_hname)
for _k in _keys_to_strip:
del _headers[_k]
if not _headers:
config.pop("headers", None)

return config
30 changes: 9 additions & 21 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,15 @@ def compile(
config_target = None
apm_yml_path = Path(APM_YML_FILENAME)
if apm_yml_path.exists():
apm_pkg = APMPackage.from_apm_yml(apm_yml_path)
config_target = apm_pkg.target
try:
apm_pkg = APMPackage.from_apm_yml(apm_yml_path)
config_target = apm_pkg.target
except FileNotFoundError:
pass
except Exception as exc:
logger.warning(
f"Could not load apm.yml: {exc}. Proceeding with auto-detection."
)

# Resolve list targets to compiler-understood value
compile_target = _resolve_compile_target(target)
Expand Down Expand Up @@ -512,22 +519,9 @@ def compile(
if result.success:
# Handle different compilation modes
if config.strategy == "distributed" and not single_agents:
# Distributed compilation results - output already shown by professional formatter
# Just show final success message
if dry_run:
# Success message for dry run already included in formatter output
pass
else:
# Defense-in-depth (#820): don't claim "completed
# successfully" when zero files were emitted. With
# parse_target_field as the upstream gatekeeper this is
# unreachable in normal flow, but silent zero-effect
# success is the worst-case package-manager DX.
#
# Pattern-based stat scan (instead of a hardcoded key
# list) so new compile-time targets pick up the guard
# automatically: any stat ending in ``_files_written``
# or ``_files_generated`` contributes to the total.
_files_written = sum(
int(v or 0)
for k, v in result.stats.items()
Expand All @@ -539,12 +533,6 @@ def compile(
symbol="check",
)
else:
# Zero-output compile is the silent-success failure
# mode #820 guards against. Don't claim success;
# surface what the user can act on. The cause is
# usually one of: target dirs not present (auto-
# detect found nothing), explicit target rejected
# by policy, or no primitives in the project.
logger.warning(
"Compilation completed but produced no output "
"files. Check that target directories exist "
Expand Down
4 changes: 0 additions & 4 deletions src/apm_cli/install/phases/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,6 @@ def run(ctx: "InstallContext") -> None:
)
raise SystemExit(1)

# Log target detection results. The empty-targets branch is a defensive
# warning -- with parse_target_field as the upstream gatekeeper this
# state is unreachable in normal flow, but a silent zero-target install
# is the worst-case package-manager DX (see #820), so always emit.
if ctx.logger:
_scope_label = "global" if _is_user else "project"
if _targets:
Expand Down
7 changes: 2 additions & 5 deletions src/apm_cli/policy/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,8 @@ def load_policy(source: Union[str, Path]) -> Tuple[ApmPolicy, List[str]]:
path = Path(source)
try:
is_file = path.is_file()
except OSError as exc:
if exc.errno == errno.ENAMETOOLONG:
is_file = False
else:
raise
except (OSError, ValueError):
is_file = False

if is_file:
raw = path.read_text(encoding="utf-8")
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/policy/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,5 +383,31 @@ def test_load_from_pathlib_path(self):
os.unlink(str(path))


class TestLoadPolicyYamlStringEdgeCases(unittest.TestCase):
"""Test load_policy with YAML string inputs that could trigger OS errors."""

def test_long_yaml_string_treated_as_string_not_path(self):
"""A YAML string longer than PATH_MAX should not crash with OSError."""
yaml_str = "name: " + "x" * 2000 + "\nversion: '1.0'"
policy, warnings = load_policy(yaml_str)
# The name will be truncated by YAML parsing but shouldn't crash
self.assertIsInstance(policy, ApmPolicy)

def test_yaml_string_with_null_bytes_raises_gracefully(self):
"""A YAML string with embedded null bytes should be treated as string."""
# Windows may raise ValueError for paths with null bytes
yaml_str = "name: test\nversion: '0.1'"
policy, warnings = load_policy(yaml_str)
self.assertIsInstance(policy, ApmPolicy)
self.assertEqual(policy.name, "test")

def test_yaml_string_with_special_characters(self):
"""YAML strings with special characters should parse correctly."""
yaml_str = "name: special-chars-@#$%\nversion: '1.0'\nenforcement: warn"
policy, warnings = load_policy(yaml_str)
self.assertEqual(policy.name, "special-chars-@#$%")
self.assertEqual(policy.enforcement, "warn")


if __name__ == "__main__":
unittest.main()
Loading