diff --git a/CHANGELOG.md b/CHANGELOG.md index eb822bd4b..705b9a542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The shared gh-aw workflow `.github/workflows/shared/apm.md` exposes an optional `apm-version` import input that pins the apm CLI version for both the pack and restore `microsoft/apm-action` steps (so the two cannot skew), surviving `gh aw update` without hand-editing the vendored file. Omitting it falls through to the action's pinned default via a gh-aw schema default, so non-opting consumers stay reproducible instead of floating to `latest`. (#1842) +- `apm config set target ` configures a default install target so a bare + `apm install` deploys to it -- set the target once, then install everywhere + without repeating `--target`. Precedence is `--target` > `apm.yml` `target:` > + config default > auto-detect, and `apm config get/unset target` inspect and + clear it. (#1881) - Org-wide policy discovery now cascades through candidate repo names (`.github`, then `.apm`, then `_apm`) and speaks the Azure DevOps Items API, so Azure DevOps organizations -- which forbid repo names that begin diff --git a/docs/src/content/docs/reference/cli/config.md b/docs/src/content/docs/reference/cli/config.md index 953fdeecc..a54ffb933 100644 --- a/docs/src/content/docs/reference/cli/config.md +++ b/docs/src/content/docs/reference/cli/config.md @@ -40,19 +40,21 @@ Print the value of `KEY`. With no argument, prints all user-settable keys with t Write `KEY` to `~/.apm/config.json`. Validates the value before writing: - `temp-dir` must be an existing, writable directory. The path is expanded (`~`) and stored absolute. +- `target` must be a valid install target token (or comma-separated list), using the same validator as `apm install --target`. - `copilot-cowork-skills-dir` must be absolute after expansion; the directory itself does not need to exist. - `mcp-registry-url` must be an `http://` or `https://` URL with a valid host. All other schemes are rejected. - Boolean keys reject anything outside the accepted truthy/falsy strings. ### `apm config unset KEY` -Remove `KEY` from `~/.apm/config.json`. No-op if the key is not set. Supported unset keys: `temp-dir`, `copilot-cowork-skills-dir`, `prefer-ssh`, `allow-protocol-fallback`, `audit-on-install`, `external..{llm,args}`, `mcp-registry-url`, and `registry..{url,token,default}`. After unsetting a key the effective value falls back to the environment variable, then the built-in default. Other boolean keys are reset by `set`-ing them to their default. +Remove `KEY` from `~/.apm/config.json`. No-op if the key is not set. Supported unset keys: `target`, `temp-dir`, `copilot-cowork-skills-dir`, `prefer-ssh`, `allow-protocol-fallback`, `audit-on-install`, `external..{llm,args}`, `mcp-registry-url`, and `registry..{url,token,default}`. After unsetting a key the effective value falls back to the environment variable, then the built-in default. Other boolean keys are reset by `set`-ing them to their default. ## Configuration keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `auto-integrate` | boolean | `true` | Auto-discover `.prompt.md` files under `.github/prompts/` and `.apm/prompts/` and merge them into compiled `AGENTS.md` output. | +| `target` | target token | unset | Default target for installs when `--target` and `apm.yml target(s)` are absent. Uses the same parser as `apm install --target` (single or comma-separated). | | `temp-dir` | path | system temp | Directory used for clone and download operations. Useful when the OS temp directory is locked down (for example, corporate Windows endpoints rejecting `%TEMP%` with `[WinError 5]`). | | `allow-protocol-fallback` | boolean | `false` | Enable the legacy cross-protocol fallback chain. When true, APM retries a failed clone with the opposite protocol (SSH→HTTPS or HTTPS→SSH). Equivalent to `--allow-protocol-fallback` or `APM_ALLOW_PROTOCOL_FALLBACK=1`. | | `prefer-ssh` | boolean | `false` | Prefer SSH transport for shorthand (`owner/repo`) dependencies. Equivalent to `--ssh` or `APM_GIT_PROTOCOL=ssh`. | @@ -120,6 +122,15 @@ apm config get auto-integrate apm config set auto-integrate false ``` +Persist a default install target: + +```bash +apm config set target claude # set the default once +apm config get target # claude +apm install # no --target needed: deploys to claude +apm config unset target # clear it (back to auto-detection) +``` + Persist SSH transport preference (no more `--ssh` on every install): ```bash @@ -199,11 +210,11 @@ See [External scanners](../../../integrations/external-scanners/). - **Format:** JSON object, one entry per stored key. - **Created on first read** with `{"default_client": "vscode"}`. Hand-editing is supported but `apm config set` is preferred -- it validates input and normalizes paths. -Internal JSON keys use snake_case (`auto_integrate`, `temp_dir`, `allow_protocol_fallback`, `prefer_ssh`, `copilot_cowork_skills_dir`); CLI keys use kebab-case. The CLI translates between the two. +Internal JSON keys use snake_case (`auto_integrate`, `install_target`, `temp_dir`, `allow_protocol_fallback`, `prefer_ssh`, `copilot_cowork_skills_dir`); CLI keys use kebab-case (the CLI `target` key is stored as `install_target`). The CLI translates between the two. ## Related -- [`apm install`](../install/) -- consumes `temp-dir` for clone/download work and `allow-protocol-fallback` / `prefer-ssh` for transport selection. +- [`apm install`](../install/) -- consumes `target`, `temp-dir`, and `allow-protocol-fallback` / `prefer-ssh`. - [`apm compile`](../compile/) -- affected by `auto-integrate`. - [`apm experimental`](../experimental/) -- gates `copilot-cowork-skills-dir` and `registry.*` keys. - [Environment variables](../../environment-variables/) -- `APM_ALLOW_PROTOCOL_FALLBACK`, `APM_GIT_PROTOCOL` are the env-var equivalents of the transport keys. diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index c44b428b1..8a1782cce 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`, `windsurf`, `kiro`, `intellij`, `agent-skills`, `all`; experimental `copilot-cowork` and `copilot-app` are also accepted when enabled. `all` expands to every harness above except `agent-skills`; combine `all,agent-skills` for both. Highest precedence in the chain `--target` > `apm.yml targets:` > 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`, `windsurf`, `kiro`, `intellij`, `agent-skills`, `all`; experimental `copilot-cowork` and `copilot-app` are also accepted when enabled. `all` expands to every harness above except `agent-skills`; combine `all,agent-skills` for both. 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. | @@ -195,7 +195,7 @@ apm install owner/skill-bundle --skill '*' # reset to all skills |---|---| | `0` | Success. All requested dependencies and local content deployed. | | `1` | Install failure: security scan blocked a critical finding, auth error, manifest write error, dependency resolution error, `--frozen` with a missing lockfile or a direct dependency absent from `apm.lock.yaml`, any reported install error (the diagnostic summary closes with `Installation failed with N error(s)`), or unhandled exception. `--force` does **not** suppress general install errors. The diagnostic summary names the cause. | -| `2` | Usage error: no deployment target detectable (no `--target`, no `targets:` in `apm.yml`, no harness signal in the project), `--ssh` and `--https` both passed, `--frozen` and `--update` both passed, `--root` combined with `--global`, or a Click flag conflict. | +| `2` | Usage error: no deployment target detectable (no `--target`, no `target(s):` in `apm.yml`, no default target configured via `apm config set target `, and no harness signal in the project), `--ssh` and `--https` both passed, `--frozen` and `--update` both passed, `--root` combined with `--global`, or a Click flag conflict. | ## Notes diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 89f895ef7..dca84fbe9 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -36,7 +36,8 @@ 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. 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. +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. `apm install` prints a one-line provenance summary before any mutation: @@ -211,9 +212,9 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning, | Command | Purpose | Key flags | |---------|---------|-----------| | `apm config` | Show current configuration | -- | -| `apm config get [KEY]` | Get a config value (`auto-integrate`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | -| `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `mcp-registry-url`; `copilot-cowork-skills-dir` requires `apm experimental enable copilot-cowork`) | -- | -| `apm config unset KEY` | Remove a stored config value (`temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | +| `apm config get [KEY]` | Get a config value (`auto-integrate`, `target`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | +| `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `target`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `mcp-registry-url`; `copilot-cowork-skills-dir` requires `apm experimental enable copilot-cowork`) | -- | +| `apm config unset KEY` | Remove a stored config value (`target`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | | `apm lock` | Resolve all dependencies in `apm.yml` and write `apm.lock.yaml` **without** deploying any files to agent targets. Mirrors `cargo generate-lockfile` / `pnpm lock`. Use to bootstrap or refresh the lockfile before reviewing and applying changes. | `--update` re-resolve to latest SHAs, `--verbose`, `-g/--global`, `--no-policy`, `--target` (comma-separated), `--parallel-downloads N` | | `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`) | | `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | @@ -221,6 +222,8 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning, `apm config set prefer-ssh true` and `apm config set allow-protocol-fallback true` persist transport preferences to `~/.apm/config.json` so SSH-only and corporate GHES users no longer need to re-pass `--ssh` / `--allow-protocol-fallback` on every `apm install`. Resolution order: CLI flag > `APM_GIT_PROTOCOL` / `APM_ALLOW_PROTOCOL_FALLBACK` env var > `apm config` value > built-in default (`false`). `apm config unset prefer-ssh` and `apm config unset allow-protocol-fallback` remove the persisted value. In `apm config` / `apm config get` (no key), the two transport rows surface only when they have been enabled (the `false`-default rows are suppressed to keep the output noise-free); `apm config get ` always returns the effective value. Setting `allow-protocol-fallback=true` while `CI=1` emits a warning because the persisted value affects every subsequent `apm install` on a shared `$HOME`; prefer the env var in CI. +`apm config set target ` persists a default install target (single token or comma-separated list) for `apm install` when both `--target` and `apm.yml target(s)` are absent. `apm config unset target` removes this fallback. + `apm self-update` shares the Windows installer codepath used by `install.ps1`: it stages the new release under `%LOCALAPPDATA%\Programs\apm\releases\` before running `apm.exe --version`, so an AppLocker / WDAC allow-list rule for `%LOCALAPPDATA%\Programs\apm\*` suffices. When the smoke test fails with HRESULT `0x80070005` (`Access is denied`), the installer emits a specific AppLocker/WDAC diagnostic with three remediations (allow-list rule, set `APM_TEMP_DIR` to an allow-listed path, or fall back to `pip install --user apm-cli`) instead of silently retrying via pip. `apm self-update` (and the startup version-checker) honours the same env vars as `install.sh` for air-gapped and GitHub Enterprise Server (GHE) environments: `GITHUB_URL` overrides the GitHub base URL and API host (`{GITHUB_URL}/api/v3` for GHE), `APM_REPO` overrides the repository (default `microsoft/apm`), and `VERSION` pins a release and skips the GitHub API call entirely. Example: `GITHUB_URL=https://gh.corp.com APM_REPO=corp/apm VERSION=v1.2.3 apm self-update`. diff --git a/src/apm_cli/commands/config.py b/src/apm_cli/commands/config.py index 844f3b5b0..09a5cfdc4 100644 --- a/src/apm_cli/commands/config.py +++ b/src/apm_cli/commands/config.py @@ -29,6 +29,13 @@ } +def _render_target_value(value: str | list[str]) -> str: + """Render a target value for CLI output.""" + if isinstance(value, list): + return ",".join(value) + return value + + def _parse_bool_value(value: str) -> bool: """Parse a CLI boolean value.""" normalized = value.strip().lower() @@ -67,6 +74,7 @@ def _valid_config_keys() -> str: keys = [ "auto-integrate", + "target", "mcp-registry-url", "temp-dir", "allow-protocol-fallback", @@ -344,6 +352,18 @@ def set(key, value): # noqa: F811 sys.exit(1) return + if key == "target": + from ..config import set_install_target + + try: + parsed = set_install_target(value) + rendered = _render_target_value(parsed) + logger.success(f"Default install target set to: {rendered}") + except ValueError as exc: + logger.error(str(exc)) + sys.exit(1) + return + if key == "mcp-registry-url": from ..config import get_mcp_registry_url, set_mcp_registry_url @@ -465,6 +485,16 @@ def get(key): click.echo(f"temp-dir: {value}") return + if key == "target": + from ..config import get_install_target + + value = get_install_target() + if value is None: + click.echo("target: Not set (using auto-detection)") + else: + click.echo(f"target: {_render_target_value(value)}") + return + if key == "mcp-registry-url": from ..config import get_mcp_registry_url @@ -526,6 +556,11 @@ def get(key): click.echo( f" temp-dir: {temp_dir if temp_dir is not None else 'Not set (using system default)'}" ) + from ..config import get_install_target as _get_install_target + + _install_target = _get_install_target() + if _install_target is not None: + click.echo(f" target: {_render_target_value(_install_target)}") # Only show transport keys when non-default to reduce noise. _apf_val = get_allow_protocol_fallback() _ssh_val = get_prefer_ssh() @@ -609,6 +644,13 @@ def unset(key): logger.success("Temporary directory configuration removed") return + if key == "target": + from ..config import unset_install_target + + unset_install_target() + logger.success("Default install target removed (will fall back to auto-detection)") + return + if key == "mcp-registry-url": from ..config import unset_mcp_registry_url diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 3e5aa8f9c..92e24cb77 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -944,7 +944,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: > 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. 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.", ) @click.option( "--allow-insecure", @@ -999,6 +999,7 @@ def _handle_mcp_install( "Add an MCP server entry to apm.yml. Use with --transport, --url, --env, " "--header, --mcp-version, or a stdio command after `--`. Resolves active " "targets the same way `apm install` does (--target > apm.yml targets: > " + "apm config target > " "auto-detect); writes only for active targets, skips others with [i]." ), ) diff --git a/src/apm_cli/config.py b/src/apm_cli/config.py index c4f858890..5d0ea609e 100644 --- a/src/apm_cli/config.py +++ b/src/apm_cli/config.py @@ -13,6 +13,7 @@ CONFIG_DIR = os.path.expanduser("~/.apm") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") +_INSTALL_TARGET_KEY = "install_target" _config_cache: dict | None = None @@ -170,6 +171,47 @@ def unset_temp_dir() -> None: _unset_config_key("temp_dir") +def get_install_target() -> str | list[str] | None: + """Get the configured default target used by ``apm install``. + + Returns: + Parsed target value from config, or ``None`` when unset/invalid. + """ + from apm_cli.core.target_detection import parse_target_field + + value = get_config().get(_INSTALL_TARGET_KEY) + try: + return parse_target_field(value) + except ValueError: + return None + + +def set_install_target(target: str) -> str | list[str]: + """Persist a default install target after validation. + + Args: + target: Target token or comma-separated target list. + + Returns: + Parsed/normalized target value that was persisted. + + Raises: + ValueError: If *target* is not a valid target expression. + """ + from apm_cli.core.target_detection import parse_target_field + + parsed = parse_target_field(target) + if parsed is None: + raise ValueError("Invalid target: target value must not be empty") + update_config({_INSTALL_TARGET_KEY: parsed}) + return parsed + + +def unset_install_target() -> None: + """Remove the default install target from the config file.""" + _unset_config_key(_INSTALL_TARGET_KEY) + + # --------------------------------------------------------------------------- # Protocol transport preferences (issue #1243) # --------------------------------------------------------------------------- diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 7c913437d..39183a248 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -766,10 +766,17 @@ def resolve_targets( *, flag: str | list[str] | None = None, yaml_targets: list[str] | None = None, + flag_source: str = "--target flag", ) -> ResolvedTargets: """Resolve effective targets. Raises on error. Priority: flag > yaml_targets > auto-detect signals. + + ``flag_source`` labels the provenance reported when ``flag`` wins. It + defaults to ``"--target flag"`` (an explicit CLI selector) but callers + pass a different label -- e.g. ``"apm config target"`` -- when the flag + value originated from a configured default rather than the CLI, so the + provenance line does not misattribute a config default to ``--target``. """ from apm_cli.core.errors import ( AmbiguousHarnessError, @@ -784,7 +791,7 @@ def resolve_targets( _validate_canonical_v2(tokens) return ResolvedTargets( targets=sorted(tokens), - source="--target flag", + source=flag_source, auto_create=True, ) diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index d1a910607..3cf800f6f 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -53,7 +53,12 @@ class InstallContext: marketplace_provenance: dict[str, Any] | None = None parallel_downloads: int = 4 logger: Any = None # InstallLogger - target_override: str | None = None # CLI --target value + target_override: str | None = None # effective --target value (CLI or config default) + # 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 + # set to "apm config target" so provenance output is not misattributed. + target_override_source: str | None = None allow_insecure: bool = False allow_insecure_hosts: tuple[str, ...] = () diff --git a/src/apm_cli/install/phases/targets.py b/src/apm_cli/install/phases/targets.py index 41003559f..0d3940e9e 100644 --- a/src/apm_cli/install/phases/targets.py +++ b/src/apm_cli/install/phases/targets.py @@ -399,6 +399,7 @@ def _fmt_target(t: Any) -> str: ctx.project_root, flag=_v2_flag, yaml_targets=_v2_yaml, + flag_source=getattr(ctx, "target_override_source", None) or "--target flag", ) except _click.UsageError as exc: if ctx.logger: @@ -464,7 +465,21 @@ def run(ctx: InstallContext) -> None: except _click.UsageError as exc: _raise_target_usage_error(ctx, exc) - # Resolve effective explicit target: CLI --target wins, then apm.yml + default_target = None + if ctx.target_override is None and config_target is None: + from apm_cli.config import get_install_target + + default_target = get_install_target() + if default_target is not None: + # Treat configured default target exactly like an explicit selector + # for this invocation so downstream phases and policy checks see + # the same effective value. Record the provenance separately so the + # resolution output does not misattribute it to a CLI --target flag. + ctx.target_override = default_target + ctx.target_override_source = "apm config target" + + # Resolve effective explicit target: CLI --target wins, then apm.yml, + # then user-scoped config default target. _explicit = ctx.target_override or config_target or None # ------------------------------------------------------------------ diff --git a/tests/integration/test_target_resolution_e2e.py b/tests/integration/test_target_resolution_e2e.py index fa5a65754..1e75d42e9 100644 --- a/tests/integration/test_target_resolution_e2e.py +++ b/tests/integration/test_target_resolution_e2e.py @@ -495,3 +495,61 @@ def test_s25_copilot_alias_in_greenfield(tmp_path): result = _invoke(["install", "--target", "copilot"], project) assert result.exit_code == 0, result.output assert_provenance(result.output, targets=["copilot"], source="--target flag") + + +# --------------------------------------------------------------------------- +# S26, S27: configurable default install target via `apm config target` +# --------------------------------------------------------------------------- + + +def test_s26_config_default_fills_greenfield_no_harness(tmp_path, monkeypatch): + """S26: `apm config target` default makes bare install succeed in greenfield. + + Without --target and without apm.yml target, a configured default target + must fill the gap that would otherwise error with 'no harness detected'. + Provenance must report 'apm config target', not '--target flag'. + """ + import apm_cli.config as config_mod + + project = _setup(tmp_path, "s04_greenfield_explicit") + + # Redirect config to tmp to avoid touching ~/.apm + config_dir = tmp_path / ".apm" + config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(config_mod, "CONFIG_DIR", str(config_dir)) + monkeypatch.setattr(config_mod, "CONFIG_FILE", str(config_dir / "config.json")) + monkeypatch.setattr(config_mod, "_config_cache", None) + + config_mod.set_install_target("claude") + + result = _invoke(["install"], project) + assert result.exit_code == 0, result.output + assert_provenance(result.output, targets=["claude"], source="apm config target") + assert (project / ".claude").is_dir() + + +def test_s27_cli_target_overrides_config_default(tmp_path, monkeypatch): + """S27: --target flag takes precedence over `apm config target` default. + + Even when a config default is set, the explicit CLI --target must win. + Provenance must report '--target flag', not 'apm config target'. + This is the regression trap for the precedence chain: + CLI --target > apm.yml > apm config target > auto-detect. + """ + import apm_cli.config as config_mod + + project = _setup(tmp_path, "s04_greenfield_explicit") + + # Set config default to copilot (deliberately different from CLI flag) + config_dir = tmp_path / ".apm" + config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(config_mod, "CONFIG_DIR", str(config_dir)) + monkeypatch.setattr(config_mod, "CONFIG_FILE", str(config_dir / "config.json")) + monkeypatch.setattr(config_mod, "_config_cache", None) + + config_mod.set_install_target("copilot") + + result = _invoke(["install", "--target", "claude"], project) + assert result.exit_code == 0, result.output + assert_provenance(result.output, targets=["claude"], source="--target flag") + assert (project / ".claude").is_dir() diff --git a/tests/unit/core/test_target_resolution_v2.py b/tests/unit/core/test_target_resolution_v2.py index 19832638a..d0a2e4cb3 100644 --- a/tests/unit/core/test_target_resolution_v2.py +++ b/tests/unit/core/test_target_resolution_v2.py @@ -160,6 +160,24 @@ def test_resolution_priority_flag_over_yaml(tmp_path): assert "--target flag" in resolved.source +def test_flag_source_label_overrides_default_provenance(tmp_path): + """A config-supplied flag value labels provenance as its own source. + + When the resolved flag value originated from a configured default rather + than the CLI, the caller passes ``flag_source`` so the provenance line is + not misattributed to ``--target``. + """ + resolved = resolve_targets(tmp_path, flag="claude", flag_source="apm config target") + assert resolved.targets == ["claude"] + assert resolved.source == "apm config target" + + +def test_flag_source_defaults_to_cli_flag(tmp_path): + """Omitting ``flag_source`` keeps the explicit --target provenance label.""" + resolved = resolve_targets(tmp_path, flag="claude") + assert resolved.source == "--target flag" + + def test_resolution_priority_yaml_over_autodetect(tmp_path): _touch(tmp_path / "CLAUDE.md") resolved = resolve_targets(tmp_path, flag=None, yaml_targets=["copilot"]) diff --git a/tests/unit/install/phases/test_targets_phase.py b/tests/unit/install/phases/test_targets_phase.py index 42687a126..a4d54510e 100644 --- a/tests/unit/install/phases/test_targets_phase.py +++ b/tests/unit/install/phases/test_targets_phase.py @@ -126,6 +126,100 @@ def test_run_conflicting_target_fields_exits_with_usage_code(tmp_path: Path) -> ctx.logger.error.assert_called_once() +def test_config_default_target_used_when_cli_and_manifest_targets_absent( + tmp_path: Path, +) -> None: + """Uses config target as fallback when no --target or apm.yml target is set.""" + from apm_cli.install.phases.targets import run + from apm_cli.models.apm_package import APMPackage + + project = tmp_path / "project" + project.mkdir() + (project / "apm.yml").write_text("name: demo\nversion: 0.1.0\n", encoding="utf-8") + ctx = _make_ctx(tmp_path) + ctx.project_root = project + ctx.apm_package = APMPackage.from_apm_yml(project / "apm.yml") + + with patch("apm_cli.config.get_install_target", return_value="claude"): + run(ctx) + + assert [target.name for target in ctx.targets] == ["claude"] + assert (project / ".claude").is_dir() + + +def test_manifest_target_wins_over_config_default_target(tmp_path: Path) -> None: + """apm.yml target keeps precedence over config default target.""" + from apm_cli.install.phases.targets import run + from apm_cli.models.apm_package import APMPackage + + project = tmp_path / "project" + project.mkdir() + (project / "apm.yml").write_text( + "name: demo\nversion: 0.1.0\ntarget: copilot\n", + encoding="utf-8", + ) + ctx = _make_ctx(tmp_path) + ctx.project_root = project + ctx.apm_package = APMPackage.from_apm_yml(project / "apm.yml") + + with patch("apm_cli.config.get_install_target", return_value="claude"): + run(ctx) + + assert [target.name for target in ctx.targets] == ["copilot"] + assert (project / ".github").is_dir() + + +def test_cli_target_wins_over_config_default_target(tmp_path: Path) -> None: + """An explicit --target selector keeps precedence over the config default. + + Regression trap for the top slot of the precedence chain + (CLI --target > apm.yml > apm config target > auto-detect). The guard in + phases/targets.py only consults the configured default when + ``ctx.target_override`` is unset; if that guard regresses, a config default + would clobber an explicit CLI selection. + """ + from apm_cli.install.phases.targets import run + from apm_cli.models.apm_package import APMPackage + + project = tmp_path / "project" + project.mkdir() + (project / "apm.yml").write_text("name: demo\nversion: 0.1.0\n", encoding="utf-8") + ctx = _make_ctx(tmp_path, target_override="copilot") + ctx.project_root = project + ctx.apm_package = APMPackage.from_apm_yml(project / "apm.yml") + + with patch("apm_cli.config.get_install_target", return_value="claude"): + run(ctx) + + assert [target.name for target in ctx.targets] == ["copilot"] + assert (project / ".github").is_dir() + + +def test_config_default_target_provenance_names_config_source(tmp_path: Path, capsys: Any) -> None: + """A config-default install reports 'apm config target' as its provenance. + + Without the provenance discriminant the bare `apm install` resolution path + misattributes a configured default to the `--target` flag. + """ + from apm_cli.install.phases.targets import run + from apm_cli.models.apm_package import APMPackage + + project = tmp_path / "project" + project.mkdir() + (project / "apm.yml").write_text("name: demo\nversion: 0.1.0\n", encoding="utf-8") + ctx = _make_ctx(tmp_path) + ctx.project_root = project + ctx.apm_package = APMPackage.from_apm_yml(project / "apm.yml") + + with patch("apm_cli.config.get_install_target", return_value="claude"): + run(ctx) + + out = capsys.readouterr().out + assert "source: apm config target" in out + assert "source: --target flag" not in out + assert ctx.target_override == "claude" # governance parity preserved + + # --------------------------------------------------------------------------- # TestProjectScopeGateForCowork # --------------------------------------------------------------------------- diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index ade166325..10a49d468 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -86,6 +86,30 @@ def test_corrupt_value_falls_back_to_default(self, isolated_config): assert config_mod.get_audit_on_install() == "off" +class TestInstallTargetConfig: + """get/set/unset for install default target.""" + + def test_default_is_none(self, isolated_config): + assert config_mod.get_install_target() is None + + def test_set_and_get_roundtrip(self, isolated_config): + config_mod.set_install_target("claude") + assert config_mod.get_install_target() == "claude" + + def test_set_rejects_invalid(self, isolated_config): + with pytest.raises(ValueError, match="is not a valid target"): + config_mod.set_install_target("MyAIProvider") + + def test_unset_removes_key(self, isolated_config): + config_mod.set_install_target("claude") + config_mod.unset_install_target() + assert config_mod.get_install_target() is None + + def test_corrupt_value_falls_back_to_none(self, isolated_config): + config_mod.update_config({"install_target": "not-a-target"}) + assert config_mod.get_install_target() is None + + class TestExternalScannerOptions: """Round-trip the external_scanners config helpers.""" diff --git a/tests/unit/test_config_command.py b/tests/unit/test_config_command.py index 2f6faffe3..1a5db4bb2 100644 --- a/tests/unit/test_config_command.py +++ b/tests/unit/test_config_command.py @@ -229,6 +229,22 @@ def test_set_auto_integrate_case_insensitive(self): assert result.exit_code == 0 mock_set.assert_called_once_with(True) + def test_set_target(self): + """Set install default target.""" + with patch("apm_cli.config.set_install_target", return_value="claude") as mock_set: + result = self.runner.invoke(config, ["set", "target", "claude"]) + assert result.exit_code == 0 + mock_set.assert_called_once_with("claude") + + def test_set_target_invalid_value(self): + """Reject invalid target value.""" + with patch( + "apm_cli.config.set_install_target", + side_effect=ValueError("Invalid target: 'oops' is not a valid target"), + ): + result = self.runner.invoke(config, ["set", "target", "oops"]) + assert result.exit_code == 1 + class TestConfigGet: """Tests for `apm config get [key]`.""" @@ -271,6 +287,20 @@ def test_get_all_config_fresh_install(self): assert result.exit_code == 0 assert "auto-integrate: true" in result.output + def test_get_target_when_set(self): + """Get configured default install target.""" + with patch("apm_cli.config.get_install_target", return_value="claude"): + result = self.runner.invoke(config, ["get", "target"]) + assert result.exit_code == 0 + assert "target: claude" in result.output + + def test_get_target_when_unset(self): + """Show not-set message for default install target.""" + with patch("apm_cli.config.get_install_target", return_value=None): + result = self.runner.invoke(config, ["get", "target"]) + assert result.exit_code == 0 + assert "target: Not set (using auto-detection)" in result.output + class TestAutoIntegrateFunctions: """Tests for get_auto_integrate and set_auto_integrate in apm_cli.config.""" @@ -762,6 +792,13 @@ def test_unset_temp_dir_exits_0(self): assert result.exit_code == 0 mock_unset.assert_called_once() + def test_unset_target_exits_0(self): + """apm config unset target exits 0.""" + with patch("apm_cli.config.unset_install_target") as mock_unset: + result = self.runner.invoke(config, ["unset", "target"]) + assert result.exit_code == 0 + mock_unset.assert_called_once() + def test_unset_unknown_key_exits_1(self): """Unsetting an unknown key exits 1 with an informative error.""" result = self.runner.invoke(config, ["unset", "unknown-key"])