Skip to content

Commit 622cd75

Browse files
fix(install): honor targets: (plural) without singular target: present (closes #1503) (#1560)
* fix(install): honor plural targets without copilot fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(install): preserve target conflict usage errors Ensure plural-target parsing errors raised from the package model stay on the targets-phase UsageError path with logger formatting and exit code 2, addressing Copilot inline review and panel follow-ups for PR #1560. Add regression traps for run() and run_targets_phase(), plus the release-note entry for issue #1503. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0252612 commit 622cd75

4 files changed

Lines changed: 153 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `apm install` now honors manifest `targets:` without falling back to the legacy Copilot target when singular `target:` is absent. (#1560)
13+
1014
## [0.16.0] - 2026-05-28
1115

1216
### Added

src/apm_cli/install/phases/targets.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,54 @@
1919
from apm_cli.integration.targets import TargetProfile
2020

2121

22+
def _package_field(apm_package: Any, name: str) -> Any:
23+
"""Return a real APMPackage field without treating MagicMock attrs as set."""
24+
if apm_package is None:
25+
return None
26+
try:
27+
attrs = vars(apm_package)
28+
except TypeError:
29+
attrs = {}
30+
if name in attrs:
31+
return attrs[name]
32+
value = getattr(apm_package, name, None)
33+
if type(value).__module__ == "unittest.mock":
34+
return None
35+
return value
36+
37+
38+
def _package_target_value(apm_package: Any) -> str | list[str] | None:
39+
"""Read singular target or plural targets from the parsed package model."""
40+
from apm_cli.core.apm_yml import parse_targets_field
41+
42+
target = _package_field(apm_package, "target")
43+
targets = _package_field(apm_package, "targets")
44+
if target is not None and targets is not None:
45+
parse_targets_field({"target": target, "targets": targets})
46+
if targets is not None:
47+
parsed = parse_targets_field({"targets": targets})
48+
return parsed if parsed else None
49+
return target
50+
51+
52+
def _raise_target_usage_error(ctx: Any, exc: Exception) -> None:
53+
"""Render target field user errors consistently before exiting."""
54+
if ctx.logger:
55+
ctx.logger.error(str(exc), symbol="")
56+
raise SystemExit(2) from exc
57+
58+
59+
def _as_yaml_targets(value: str | list[str] | None) -> list[str] | None:
60+
"""Normalize a package target value to the v2 yaml_targets shape."""
61+
if value is None:
62+
return None
63+
if isinstance(value, str):
64+
parts = [t.strip() for t in value.split(",") if t.strip()]
65+
else:
66+
parts = [str(t).strip() for t in value if str(t).strip()]
67+
return parts or None
68+
69+
2270
def _read_yaml_targets(ctx) -> list[str] | None:
2371
"""Read targets/target from raw apm.yml using v2 parser.
2472
@@ -29,10 +77,10 @@ def _read_yaml_targets(ctx) -> list[str] | None:
2977
return None
3078
apm_yml_path = getattr(ctx.apm_package, "package_path", None)
3179
if apm_yml_path is None:
32-
return None
80+
return _as_yaml_targets(_package_target_value(ctx.apm_package))
3381
manifest = apm_yml_path / "apm.yml"
3482
if not manifest.exists():
35-
return None
83+
return _as_yaml_targets(_package_target_value(ctx.apm_package))
3684
try:
3785
from apm_cli.utils.yaml_io import load_yaml
3886

@@ -92,6 +140,8 @@ def run(ctx: InstallContext) -> None:
92140
On return ``ctx.targets`` and ``ctx.integrators`` are populated.
93141
"""
94142

143+
import click as _click
144+
95145
from apm_cli.core.scope import InstallScope
96146
from apm_cli.core.target_detection import (
97147
detect_target,
@@ -113,8 +163,11 @@ def run(ctx: InstallContext) -> None:
113163
resolve_targets as _resolve_targets_legacy,
114164
)
115165

116-
# Get config target from apm.yml if available
117-
config_target = ctx.apm_package.target
166+
# Get config target from apm.yml if available.
167+
try:
168+
config_target = _package_target_value(ctx.apm_package)
169+
except _click.UsageError as exc:
170+
_raise_target_usage_error(ctx, exc)
118171

119172
# Resolve effective explicit target: CLI --target wins, then apm.yml
120173
_explicit = ctx.target_override or config_target or None
@@ -303,18 +356,12 @@ def run(ctx: InstallContext) -> None:
303356
# Read targets from apm.yml (supports both target: and targets:)
304357
_v2_yaml: list[str] | None = None
305358
if _v2_flag is None and not ctx.target_override:
306-
import click as _click
307-
308359
try:
309360
_v2_yaml = _read_yaml_targets(ctx)
310361
except _click.UsageError as exc:
311362
# ConflictingTargetsError (both target: and targets: in
312363
# apm.yml) is a user error -- surface with exit code 2.
313-
# The renderer already emits a leading "[x]"; pass an
314-
# empty symbol so logger.error doesn't double-prefix.
315-
if ctx.logger:
316-
ctx.logger.error(str(exc), symbol="")
317-
raise SystemExit(2) from exc
364+
_raise_target_usage_error(ctx, exc)
318365

319366
# Skip v2 entirely when all override targets were non-canonical
320367
# (e.g. copilot-cowork only). Those are fully handled by the
@@ -400,7 +447,9 @@ def run(ctx: InstallContext) -> None:
400447
# Keep any legacy-only targets (e.g. copilot-cowork) that v2
401448
# doesn't handle.
402449
_v2_names = {t.name for t in _v2_targets}
403-
_legacy_only = [t for t in _targets if t.name not in _v2_names]
450+
_legacy_only = [
451+
t for t in _targets if t.name not in _v2_names and t.name not in _CANONICAL
452+
]
404453
_targets = _v2_targets + _legacy_only
405454

406455
else:
@@ -479,6 +528,8 @@ def run_targets_phase(ctx) -> None:
479528
"""
480529
from pathlib import Path
481530

531+
import click as _click
532+
482533
from apm_cli.core.target_detection import resolve_targets
483534
from apm_cli.integration.targets import KNOWN_TARGETS
484535

@@ -494,14 +545,11 @@ def run_targets_phase(ctx) -> None:
494545
else:
495546
flag = ctx.target_override
496547

497-
# Get yaml_targets from apm_package
498-
yaml_targets: list[str] | None = None
499-
if ctx.apm_package and ctx.apm_package.target:
500-
raw = ctx.apm_package.target
501-
if isinstance(raw, str):
502-
yaml_targets = [t.strip() for t in raw.split(",") if t.strip()]
503-
elif isinstance(raw, list):
504-
yaml_targets = raw
548+
# Get yaml_targets from apm_package.
549+
try:
550+
yaml_targets = _as_yaml_targets(_package_target_value(ctx.apm_package))
551+
except _click.UsageError as exc:
552+
_raise_target_usage_error(ctx, exc)
505553

506554
# Resolve targets
507555
resolved = resolve_targets(project_root, flag=flag, yaml_targets=yaml_targets)

tests/unit/install/phases/test_targets_phase.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,56 @@ def _make_ctx(
7676
ctx.logger = MagicMock()
7777
ctx.targets = []
7878
ctx.integrators = {}
79+
ctx.legacy_skill_paths = False
7980
return ctx
8081

8182

83+
def test_plural_targets_without_singular_does_not_keep_legacy_copilot_fallback(
84+
tmp_path: Path,
85+
) -> None:
86+
"""targets: [claude] must not inherit legacy greenfield copilot fallback."""
87+
from apm_cli.install.phases.targets import run
88+
from apm_cli.models.apm_package import APMPackage
89+
90+
project = tmp_path / "project"
91+
project.mkdir()
92+
(project / "apm.yml").write_text(
93+
"name: demo\nversion: 0.1.0\ntargets:\n - claude\n",
94+
encoding="utf-8",
95+
)
96+
ctx = _make_ctx(tmp_path)
97+
ctx.project_root = project
98+
ctx.apm_package = APMPackage.from_apm_yml(project / "apm.yml")
99+
100+
run(ctx)
101+
102+
assert [target.name for target in ctx.targets] == ["claude"]
103+
assert (project / ".claude").is_dir()
104+
assert not (project / ".github").exists()
105+
106+
107+
def test_run_conflicting_target_fields_exits_with_usage_code(tmp_path: Path) -> None:
108+
"""target + targets conflicts must stay on the targets-phase error path."""
109+
from apm_cli.install.phases.targets import run
110+
from apm_cli.models.apm_package import APMPackage
111+
112+
project = tmp_path / "project"
113+
project.mkdir()
114+
(project / "apm.yml").write_text(
115+
"name: demo\nversion: 0.1.0\ntarget: claude\ntargets:\n - copilot\n",
116+
encoding="utf-8",
117+
)
118+
ctx = _make_ctx(tmp_path)
119+
ctx.project_root = project
120+
ctx.apm_package = APMPackage.from_apm_yml(project / "apm.yml")
121+
122+
with pytest.raises(SystemExit) as exc_info:
123+
run(ctx)
124+
125+
assert exc_info.value.code == 2
126+
ctx.logger.error.assert_called_once()
127+
128+
82129
# ---------------------------------------------------------------------------
83130
# TestProjectScopeGateForCowork
84131
# ---------------------------------------------------------------------------

tests/unit/install/phases/test_targets_phase_v2.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def _make_ctx(
3232
*,
3333
target_override: str | None = None,
3434
yaml_target: str | None = None,
35+
yaml_targets: list[str] | None = None,
3536
) -> MagicMock:
3637
ctx = MagicMock()
3738
ctx.project_root = project_root
@@ -40,6 +41,8 @@ def _make_ctx(
4041
ctx.target_override = target_override
4142
ctx.apm_package = MagicMock()
4243
ctx.apm_package.target = yaml_target
44+
if yaml_targets is not None:
45+
ctx.apm_package.targets = yaml_targets
4346
ctx.logger = MagicMock()
4447
ctx.targets = []
4548
ctx.integrators = {}
@@ -94,6 +97,37 @@ def test_explicit_creates_missing_dir(tmp_path):
9497
assert (project / ".claude").is_dir(), "Explicit --target claude must materialize .claude/"
9598

9699

100+
def test_plural_yaml_targets_attribute_creates_only_declared_dir(tmp_path):
101+
"""targets: from the parsed APMPackage model drives v2 target selection."""
102+
from apm_cli.install.phases.targets import run_targets_phase
103+
104+
project = tmp_path / "project"
105+
project.mkdir()
106+
107+
ctx = _make_ctx(project, yaml_targets=["claude"])
108+
run_targets_phase(ctx)
109+
110+
assert [target.name for target in ctx.targets] == ["claude"]
111+
assert (project / ".claude").is_dir()
112+
assert not (project / ".github").exists()
113+
114+
115+
def test_run_targets_phase_conflicting_target_fields_exits_with_usage_code(tmp_path: Path) -> None:
116+
"""run_targets_phase preserves usage-error exit code for target conflicts."""
117+
from apm_cli.install.phases.targets import run_targets_phase
118+
119+
project = tmp_path / "project"
120+
project.mkdir()
121+
122+
ctx = _make_ctx(project, yaml_target="claude", yaml_targets=["copilot"])
123+
124+
with pytest.raises(SystemExit) as exc_info:
125+
run_targets_phase(ctx)
126+
127+
assert exc_info.value.code == 2
128+
ctx.logger.error.assert_called_once()
129+
130+
97131
@pytest.mark.parametrize(
98132
("marker_path", "expected_dir"),
99133
[

0 commit comments

Comments
 (0)