Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added `--target/-t` option to `apm update` command to specify agent target (#1297)
- `apm pack --marketplace=FORMATS` filters which marketplace formats are built in a single run; accepts comma-separated names and sentinels `all`/`none`. (#1317)
- `apm pack --marketplace-path FORMAT=PATH` overrides the output path for a specific marketplace format at invocation time. Env var overrides (`APM_MARKETPLACE_<FORMAT>_PATH`) are planned for v0.15. (#1317)
- `apm pack --json` emits a stable JSON contract to stdout (`{ok, dry_run, warnings, errors, marketplace: {outputs: [{format, path, ...}]}}`); all logs move to stderr so downstream tooling can `jq` the output safely. (#1317)
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ For a read-only install that pins to whatever is already in `apm.lock.yaml` -- t
| `--yes`, `-y` | off | Skip the interactive prompt and accept the plan. Required for non-interactive use. |
| `--dry-run` | off | Compute and print the plan without prompting and without writing the lockfile or filesystem. |
| `--verbose`, `-v` | off | Show per-dependency resolution detail (old ref, new ref, source) and full error context. |
| `--target TARGET`, `-t TARGET` | auto-detect | Agent harness(es) to update for. Accepts a single value (`claude`, `copilot`, `cursor`, `windsurf`, `codex`, `opencode`, `gemini`) or comma-separated list (`--target claude,cursor`). Overrides `apm.yml targets:` and auto-detection. |

## Examples

Expand Down
34 changes: 34 additions & 0 deletions src/apm_cli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
* ``--dry-run`` -- render the plan and exit without prompting.
* ``--verbose``/``-v`` -- show unchanged deps in the plan and pipeline
diagnostics.
* ``--target``/``-t`` -- agent harness(es) to deploy to (e.g.
``claude``, ``copilot``, ``cursor``, ``windsurf``, ``codex``,
``opencode``, ``gemini``); comma-separated for multiple targets.
Overrides ``apm.yml targets:`` and auto-detection.

Other ``apm install`` flags are NOT mirrored here on purpose -- the
update command stays focused on the refresh-and-confirm loop.
Expand All @@ -43,6 +47,7 @@
import click

from ..core.command_logger import InstallLogger
from ..core.target_detection import TargetParamType
from ..install.errors import (
AuthenticationError,
DirectDependencyError,
Expand Down Expand Up @@ -121,13 +126,27 @@ def _stdin_is_tty() -> bool:
help="(Deprecated) Forwarded to 'apm self-update --check' when run outside an apm.yml project; rejected inside a project.",
hidden=True,
)
@click.option(
"--target",
"-t",
type=TargetParamType(),
default=None,
help=(
"Agent target(s) to update for "
"(e.g. claude, copilot, cursor, windsurf, codex, opencode, gemini). "
"Comma-separated for multiple: --target claude,cursor. "
"Highest-priority entry in the resolution chain "
"(--target > apm.yml targets: > auto-detect)."
),
)
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
@click.pass_context
def update(
ctx: click.Context,
assume_yes: bool,
dry_run: bool,
verbose: bool,
check_only: bool,
target: str | list[str] | None,
) -> None:
"""Refresh APM dependencies to the latest matching refs.
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

Expand All @@ -145,6 +164,12 @@ def update(
# the release after this one.
from apm_cli.commands.self_update import self_update as _self_update_cmd

if target is not None:
_rich_warning(
"--target is ignored when forwarding to 'apm self-update' "
"(no apm.yml found). Use 'apm self-update' directly.",
symbol="warning",
)
_rich_warning(
"'apm update' refreshes APM dependencies. To update the CLI binary, "
"use 'apm self-update'. Forwarding for back-compat (deprecated).",
Expand All @@ -156,6 +181,12 @@ def update(
if check_only:
from apm_cli.commands.self_update import self_update as _self_update_cmd

if target is not None:
_rich_warning(
"--target is ignored when forwarding to 'apm self-update --check'. "
"Use 'apm update --dry-run' to preview dependency changes.",
symbol="warning",
)
_rich_warning(
"'apm update --check' is the deprecated self-updater shim. "
"Use 'apm update --dry-run' to preview dependency changes, "
Expand All @@ -178,6 +209,7 @@ def update(
dry_run=dry_run,
verbose=verbose,
project_root=project_root,
target=target,
)


Expand All @@ -187,6 +219,7 @@ def _run_dep_update(
dry_run: bool,
verbose: bool,
project_root: Path | None = None,
target: str | list[str] | None = None,
) -> None:
"""Core ``apm update`` flow: resolve, plan, prompt, install.

Expand Down Expand Up @@ -282,6 +315,7 @@ def _plan_callback(plan: UpdatePlan) -> bool:
scope=InstallScope.PROJECT,
logger=logger,
plan_callback=_plan_callback,
target=target,
)
except FrozenInstallError as e:
_rich_error(str(e))
Expand Down
12 changes: 8 additions & 4 deletions tests/integration/test_install_local_bundle_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -1013,10 +1013,14 @@ def _capture(mcp_deps, **kwargs):
names = sorted(d.name for d in captured["deps"])
assert names == ["filesystem", "github"]

# Resolved targets reach the integrator as a CSV in
# ``explicit_target``. Both copilot and claude must appear.
target_csv = captured["kwargs"].get("explicit_target") or ""
target_set = {t.strip() for t in target_csv.split(",") if t.strip()}
# Resolved targets reach the integrator via ``explicit_target``,
# which accepts either a CSV string or a list of canonical names.
# Both copilot and claude must appear.
explicit = captured["kwargs"].get("explicit_target") or ""
if isinstance(explicit, str):
target_set = {t.strip() for t in explicit.split(",") if t.strip()}
else:
target_set = {t.strip() for t in explicit if t and t.strip()}
assert "copilot" in target_set
assert "claude" in target_set

Expand Down
133 changes: 133 additions & 0 deletions tests/unit/commands/test_update_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,139 @@ def test_update_without_apm_yml_forwards_to_self_update(self, runner, tmp_path):
assert mock_self_update.called


# -----------------------------------------------------------------------------
# apm update --target flag
# -----------------------------------------------------------------------------


class TestUpdateTarget:
def test_target_forwarded_to_install(self, runner, tmp_path):
"""--target value is passed through to _install_apm_dependencies."""
with runner.isolated_filesystem(temp_dir=tmp_path):
_make_apm_yml(Path.cwd())
captured = {}

def fake_install(_apm, **kwargs):
captured["target"] = kwargs.get("target")
cb = kwargs["plan_callback"]
cb(UpdatePlan(entries=()))
from apm_cli.models.results import InstallResult

return InstallResult()

with patch(
"apm_cli.commands.install._install_apm_dependencies", side_effect=fake_install
):
result = runner.invoke(cli, ["update", "--target", "claude"])

assert result.exit_code == 0, result.output
assert captured["target"] == "claude"

def test_short_target_flag(self, runner, tmp_path):
"""-t short form is accepted and forwarded."""
with runner.isolated_filesystem(temp_dir=tmp_path):
_make_apm_yml(Path.cwd())
captured = {}

def fake_install(_apm, **kwargs):
captured["target"] = kwargs.get("target")
cb = kwargs["plan_callback"]
cb(UpdatePlan(entries=()))
from apm_cli.models.results import InstallResult

return InstallResult()

with patch(
"apm_cli.commands.install._install_apm_dependencies", side_effect=fake_install
):
result = runner.invoke(cli, ["update", "-t", "copilot"])

assert result.exit_code == 0, result.output
assert captured["target"] == "copilot"

Comment thread
sergio-sisternes-epam marked this conversation as resolved.
def test_no_target_defaults_to_none(self, runner, tmp_path):
"""Omitting --target passes None to _install_apm_dependencies."""
with runner.isolated_filesystem(temp_dir=tmp_path):
_make_apm_yml(Path.cwd())
captured = {}

def fake_install(_apm, **kwargs):
captured["target"] = kwargs.get("target")
cb = kwargs["plan_callback"]
cb(UpdatePlan(entries=()))
from apm_cli.models.results import InstallResult

return InstallResult()

with patch(
"apm_cli.commands.install._install_apm_dependencies", side_effect=fake_install
):
result = runner.invoke(cli, ["update"])

assert result.exit_code == 0, result.output
assert captured["target"] is None

def test_target_with_assume_yes(self, runner, tmp_path):
"""--target and --yes work together; target is forwarded and install proceeds."""
with runner.isolated_filesystem(temp_dir=tmp_path):
_make_apm_yml(Path.cwd())
captured = {}

def fake_install(_apm, **kwargs):
captured["target"] = kwargs.get("target")
cb = kwargs["plan_callback"]
captured["proceeded"] = cb(_stub_plan_with_changes())
from apm_cli.models.results import InstallResult

return InstallResult(installed_count=1)

with patch(
"apm_cli.commands.install._install_apm_dependencies", side_effect=fake_install
):
result = runner.invoke(cli, ["update", "--yes", "--target", "cursor"])

assert result.exit_code == 0, result.output
assert captured["target"] == "cursor"
assert captured["proceeded"] is True

def test_multi_target_comma_separated(self, runner, tmp_path):
"""--target claude,cursor (comma-separated) is parsed to a list and forwarded."""
with runner.isolated_filesystem(temp_dir=tmp_path):
_make_apm_yml(Path.cwd())
captured = {}

def fake_install(_apm, **kwargs):
captured["target"] = kwargs.get("target")
cb = kwargs["plan_callback"]
cb(UpdatePlan(entries=()))
from apm_cli.models.results import InstallResult

return InstallResult()

with patch(
"apm_cli.commands.install._install_apm_dependencies", side_effect=fake_install
):
result = runner.invoke(cli, ["update", "--target", "claude,cursor"])

assert result.exit_code == 0, result.output
assert isinstance(captured["target"], list), (
f"Expected list for multi-target, got {type(captured['target'])}"
)
assert "claude" in captured["target"]
assert "cursor" in captured["target"]

def test_target_ignored_warning_on_shim_path(self, runner, tmp_path):
"""--target outside an apm.yml project emits a warning that it will be ignored."""
with runner.isolated_filesystem(temp_dir=tmp_path):
with patch("apm_cli.commands.self_update.self_update.callback") as mock_self_update:
mock_self_update.return_value = None
result = runner.invoke(cli, ["update", "--target", "claude"])

assert "ignored" in result.output.lower() or "warning" in result.output.lower(), (
f"Expected an ignored/warning message, got: {result.output}"
)


# -----------------------------------------------------------------------------
# apm install --frozen / --update mutex
# -----------------------------------------------------------------------------
Expand Down
Loading