Skip to content

fix(install): accept YAML list form under singular target: key (#1188)#1197

Merged
danielmeppiel merged 4 commits into
mainfrom
fix/1188-target-yaml-list
May 7, 2026
Merged

fix(install): accept YAML list form under singular target: key (#1188)#1197
danielmeppiel merged 4 commits into
mainfrom
fix/1188-target-yaml-list

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

Fixes #1188.

TL;DR

apm install now accepts target: [copilot, claude] (YAML flow-list) and the equivalent block-list form under the canonical singular target: key. Before this PR, the install pipeline crashed with Unknown target '['copilot'' while apm install --dry-run silently accepted the same config.

Problem (WHY)

Issue #1188 reports that this perfectly valid apm.yml:

target:
  - copilot
  - claude

crashes apm install with:

[x] [x] Unknown target '['copilot''
...

Two bugs surface at once:

  1. The parser bug. apm_cli.core.apm_yml.parse_targets_field does str(raw).strip() then comma-splits. When raw is a Python list, str([...]) yields the literal repr "['copilot', 'claude']", splitting on , yields "['copilot'", " 'claude']", and the first token fails validation. The targets: (plural) branch handles lists correctly — only the singular branch was broken.
  2. The dry-run lies. The dry-run path goes through a different parser (core.target_detection.parse_target_field) which DOES handle lists. So apm install --dry-run silently passes on the exact apm.yml that real apm install rejects. Anyone using dry-run to validate config was misled.

The error rendering also had three coupled UX paper cuts surfaced by this same code path:

  • Doubled [x] [x] prefix (renderer prepends one, logger.error prepends another).
  • Garbled '['copilot'' headline with unbalanced quotes.
  • The "did you mean?" suggestion is valid[0] after sorted(CANONICAL_TARGETS) = agent-skills — actively misleading when the user typed copilot/claude.

Approach (WHAT)

Surgical fix in parse_targets_field (Plan A, ratified by the devx-ux expert) plus the three tightly-coupled error-rendering fixes the cli-logging-ux expert flagged on the same code path. Eliminating the duplicate parser entirely (Plan B) is the right architectural move — core.target_detection.parse_target_field's docstring already says "Single source of truth" — but it's a refactor with non-trivial blast radius (different return-type contract, different alias-resolution behavior, different empty-list semantics) and belongs in its own PR. Filed as follow-up.

The fail-fast issue (target validation runs after 13s of dependency resolution) and a proper fuzzy-match did_you_mean helper are also follow-ups; they're feature work, not bug fixes.

Implementation (HOW)

1. src/apm_cli/core/apm_yml.py — parser

In the if has_target: branch, detect isinstance(raw, list) and tokenize the same way the targets: (plural) branch does. Empty list under the singular key falls through to auto-detect (matches target: with no value); only the plural targets: [] raises EmptyTargetsListError, which is the explicit-but-empty contradictory case.

2. src/apm_cli/core/errors.pyrender_unknown_target_error

  • Strip [, ], ', ", and spaces from the headline value (defense-in-depth for any future garbled-token leaks).
  • Use copilot as the suggested fix (or valid[0] as fallback if copilot somehow isn't in the canonical set), instead of valid[0] which after sorted(CANONICAL_TARGETS) resolves to agent-skills.

3. src/apm_cli/install/phases/targets.py — double [x] fix

Two ctx.logger.error(str(exc)) sites (lines 212, 251) were emitting already-rendered error blocks (which start with [x]) through logger.error(), which prepends another [x] via the default symbol="error". Pass symbol=None to suppress the second prefix. The renderer's leading symbol is preserved.

Tests

  • tests/unit/core/test_target_resolution_v2.py — 8 new parser cases:
    • Two-item flow list (['copilot', 'claude'])
    • Single-item list (['copilot'] parity with scalar target: copilot)
    • Whitespace tolerance in list elements
    • Empty list under singular key falls through to auto-detect
    • Unknown token in list surfaces a clean (non-list-repr) headline
    • Non-string list element coerces via str() and validates
    • 'all' mixed with canonical token is rejected (no escape hatch)
    • Duplicates preserved (parser does not dedup; matches plural branch)
  • tests/unit/core/test_error_renderer.py — 2 new renderer cases:
    • Suggestion is copilot, not the alphabetically-first agent-skills
    • Headline value is sanitized when input contains bracket/quote noise
  • tests/unit/install/phases/test_read_yaml_targets_list_form.py — new file, 6 cases on the install-pipeline read path:
    • Flow list (target: [a, b])
    • Block list (target:\n - a\n - b)
    • CSV form regression guard
    • Scalar form regression guard
    • Cross-parser shape agreement (regression-trap for dry-run-vs-real divergence)
    • Unknown token in YAML list surfaces a clean error headline

Validation

$ uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/
All checks passed!
706 files already formatted

$ uv run --extra dev pytest tests/unit/core/ tests/unit/install/ -q
910 passed in 4.79s

Manual repro on the issue's exact apm.yml:

$ python -c "from apm_cli.core.apm_yml import parse_targets_field; from apm_cli.utils.yaml_io import load_yaml; print(parse_targets_field(load_yaml(Path('apm.yml'))))"
['copilot', 'claude']

(Pre-fix on the same file: Unknown target '['copilot'' with unbalanced quotes and [x] [x] doubled prefix.)

Trade-offs

  • Plan A vs Plan B. Plan B (eliminate the duplicate parser, have _read_yaml_targets delegate to parse_target_field) is architecturally correct but blast-radius large. Plan A is 6 lines of correctness fix in one branch of one function. Filing Plan B as tech-debt with a link back here.
  • Bundled the three error-rendering fixes rather than a separate PR. Pure-cosmetic logging fixes belong in a separate PR (per the devx-ux expert), but these three are direct consequences of the parser bug on the same code path — splitting them would require this PR's tests to assert against the broken renderer, which is awkward. Trade-off accepted: the diff stays small (229 / 6) and the revert remains clean.

Follow-ups (separate issues)

  • Plan B: consolidate target parsing onto core.target_detection.parse_target_field so the install path and dry-run path share a single source of truth.
  • Fail-fast: target validation currently runs after dependency resolution (~13s wasted on the issue repro). Move it earlier in the install pipeline.
  • Fuzzy did_you_mean: a real difflib.get_close_matches()-backed suggestion helper. Hardcoded copilot fallback is fine for now.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

The install-pipeline parser (apm_yml.parse_targets_field) crashed on
'target: [copilot, claude]' because it called str(raw) on a Python
list, producing the literal repr "['copilot', 'claude']" and then
comma-splitting it into garbled tokens ("['copilot'", "'claude']").
The first token failed validation and surfaced as
'Unknown target [copilot' with unbalanced quotes.

Worse, the dry-run path uses a different parser
(target_detection.parse_target_field) which DOES handle lists, so
'apm install --dry-run' silently passed on the same apm.yml that
'apm install' rejected -- masking the bug from anyone who relied on
dry-run to validate their config.

Fix:
- apm_yml.parse_targets_field: detect isinstance(raw, list) inside
  the singular-key branch and tokenize like the plural-key branch
  does. Empty list under singular key falls through to auto-detect
  (consistent with 'target:' with no value); only PLURAL 'targets: []'
  raises EmptyTargetsListError.
- errors.render_unknown_target_error: strip bracket/quote noise from
  the headline value (defense-in-depth for any future leaks) and
  suggest 'copilot' as the default fix instead of valid[0], which
  resolves to 'agent-skills' after sorted(CANONICAL_TARGETS) and is
  actively misleading for users who typed 'copilot'/'claude'.
- install/phases/targets.py: pass symbol=None when emitting
  already-rendered TargetResolutionError messages so the error block
  doesn't get double-prefixed with '[x] [x]'.

Tests:
- tests/unit/core/test_target_resolution_v2.py: 8 new cases for
  list-form parsing (single-item, two-item, whitespace, empty,
  duplicates, unknown token, non-string, 'all' rejection).
- tests/unit/core/test_error_renderer.py: copilot-suggestion default,
  garbled-value sanitization.
- tests/unit/install/phases/test_read_yaml_targets_list_form.py: new
  file exercising the install-pipeline read path on flow-list,
  block-list, CSV, and scalar forms; includes a cross-parser shape
  check as a regression-trap for dry-run-vs-real divergence.

Plan B (eliminating the duplicate parser) is filed as a follow-up.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 7, 2026 20:29
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request fixes apm install failing to parse a YAML list under the singular target: key in apm.yml, aligning install behavior with the dry-run parser and improving the unknown-target error output.

Changes:

  • Extend parse_targets_field() to accept target: [a, b] / block-list forms under the singular target: key.
  • Improve unknown-target error rendering (sanitize headline value; prefer copilot as the suggested fix).
  • Add unit tests covering singular-key YAML list parsing and the install-pipeline read path; adjust install-phase logging to avoid double [x] prefixes.
Show a summary per file
File Description
src/apm_cli/core/apm_yml.py Accept YAML list values under singular target: and validate tokens correctly.
src/apm_cli/core/errors.py Sanitize unknown-target headline and adjust suggestion selection.
src/apm_cli/install/phases/targets.py Suppress double-prefixing when logging already-rendered [x] errors.
tests/unit/core/test_target_resolution_v2.py Add regression tests for singular target: YAML list parsing and edge cases.
tests/unit/core/test_error_renderer.py Add tests for improved unknown-target rendering and suggestion choice.
tests/unit/install/phases/test_read_yaml_targets_list_form.py New tests exercising install pipeline YAML read path for list/scalar/CSV forms.
CHANGELOG.md Add an Unreleased entry describing the fix.

Copilot's findings

  • Files reviewed: 7/7 changed files
  • Comments generated: 4

Comment thread src/apm_cli/install/phases/targets.py Outdated
Comment on lines 211 to 215
# The renderer already emits a leading "[x]"; pass
# symbol=None so logger.error doesn't double-prefix.
if ctx.logger:
ctx.logger.error(str(exc))
ctx.logger.error(str(exc), symbol=None)
raise SystemExit(2) from exc
Comment thread src/apm_cli/core/errors.py Outdated
Comment on lines +119 to +124
valid_csv = ", ".join(sorted(valid))
# Strip bracket/quote noise that can leak in from misparsed tokens
# (e.g. "['copilot'"). Defense-in-depth: callers should pass clean
# values, but this keeps the headline readable if they don't.
display_value = value.strip("[]'\" ")
suggestion = "copilot" if "copilot" in valid else (valid[0] if valid else "claude")
Comment on lines +120 to 127
# Strip bracket/quote noise that can leak in from misparsed tokens
# (e.g. "['copilot'"). Defense-in-depth: callers should pass clean
# values, but this keeps the headline readable if they don't.
display_value = value.strip("[]'\" ")
suggestion = "copilot" if "copilot" in valid else (valid[0] if valid else "claude")
return (
f"[x] Unknown target '{value}'\n"
f"[x] Unknown target '{display_value}'\n"
"\n"
Comment thread CHANGELOG.md Outdated
- Stabilized `test_install_over_defer_threshold_starts_live_once` on slow CI runners by joining the deferred-start timer thread instead of relying on a 100ms grace window. (#1191)
- `triage-panel` scheduled sweep now paginates the candidate query oldest-first via the GitHub MCP `list_issues` tool instead of a single 200-issue page, so daily runs actually drain the untriaged backlog rather than processing one issue per cron tick. (#1193)
- `triage-panel` scheduled sweep switches the candidate query from `list_issues`+prose-driven pagination to `search_issues` with `-label:status/triaged sort:created-asc`, so untriaged candidates are filtered server-side; the previous approach silently noop'd because the MCP gateway DIFC filter dropped non-collaborator issues mid-page and the agent inferred a false `hasNextPage:false`.
- `apm install` now accepts the YAML list form under `target:` (e.g. `target: [copilot, claude]` or block-list), which previously crashed with `Unknown target '['copilot''` because the install-pipeline parser stringified the list before splitting; the unknown-target error renderer also strips bracket/quote noise from the headline value and suggests `copilot` instead of the alphabetically-first `agent-skills`. (#1197)
Daniel Meppiel and others added 2 commits May 7, 2026 22:33
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- errors.py: derive fallback suggestion from sorted(valid) for stable
  ordering; fall back to raw value or '<empty>' when bracket/quote
  sanitization strips the headline to nothing.
- targets.py: pass symbol='' instead of symbol=None to honor the
  CommandLogger.error type contract (str = 'error') while still
  suppressing the double-prefix.
- CHANGELOG: shorten to one concise line per repo convention.
- tests: cover the all-noise sanitization edge case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

Addressed all four Copilot review comments in 71c982e1:

  1. symbol=Nonesymbol="" (targets.py:215, :255): honors CommandLogger.error's symbol: str = "error" type contract while still suppressing the double-prefix. Empty string isn't in STATUS_SYMBOLS, so no prefix is prepended.
  2. valid[0]sorted(valid)[0] (errors.py): both the displayed valid_csv and the fallback suggestion now derive from a single valid_sorted variable. Deterministic regardless of caller input order.
  3. Empty-after-strip headline: display_value = value.strip(...) or value or "<empty>" so a payload of pure noise ("[]'\"") no longer renders Unknown target ''. New test test_unknown_target_error_falls_back_when_strip_empties_value guards this.
  4. CHANGELOG entry shortened to one concise line per repo convention.

Lint clean, all 62 affected unit tests still pass.

@danielmeppiel danielmeppiel merged commit 73f0e9c into main May 7, 2026
9 checks passed
@danielmeppiel danielmeppiel deleted the fix/1188-target-yaml-list branch May 7, 2026 20:42
danielmeppiel added a commit that referenced this pull request May 8, 2026
* docs: update target reference for list-form support and cowork exclusion from all

- cli-commands.md: clarify copilot-cowork is excluded from --target all;
  note it requires explicit --target copilot-cowork --global (#1191)
- manifest-schema.md: add block-list YAML example for target: field,
  now that the singular target: key correctly parses list forms (#1197)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
danielmeppiel added a commit that referenced this pull request May 8, 2026
The unknown-target error renderer advertised every member of
CANONICAL_TARGETS as a recovery path, including the meta-target
"agent-skills". But "apm targets" intentionally omits "agent-skills"
from its table (it is a multi-harness fan-out target with no single
deploy_dir; visible only via "apm targets --json --all"). A user
following the error message's "apm targets  # see all supported
harnesses" hint cannot confirm "agent-skills" exists, leaving them
stuck on a contradiction APM authored itself.

Filter "agent-skills" out of the rendered "Valid targets:" CSV and
out of the "did you mean?" suggestion fallback chain. The canonical
set still ACCEPTS "agent-skills" via "--target agent-skills" and the
"target:"/"targets:" keys in apm.yml, so power users who pass it
explicitly continue to work; we just stop steering beginners toward a
target the discovery command refuses to confirm.

Adds two tests:
- agent-skills must not appear anywhere in the unknown-target render.
- If the caller filters down to only agent-skills, the renderer must
  fall back to a sane default suggestion ("claude") rather than
  re-introducing agent-skills.

Closes #1208 (paired with #1197 which fixed the parser crash).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
danielmeppiel added a commit that referenced this pull request May 8, 2026
The unknown-target error renderer advertised every member of
CANONICAL_TARGETS as a recovery path, including the meta-target
"agent-skills". But "apm targets" intentionally omits "agent-skills"
from its table (it is a multi-harness fan-out target with no single
deploy_dir; visible only via "apm targets --json --all"). A user
following the error message's "apm targets  # see all supported
harnesses" hint cannot confirm "agent-skills" exists, leaving them
stuck on a contradiction APM authored itself.

Filter "agent-skills" out of the rendered "Valid targets:" CSV and
out of the "did you mean?" suggestion fallback chain. The canonical
set still ACCEPTS "agent-skills" via "--target agent-skills" and the
"target:"/"targets:" keys in apm.yml, so power users who pass it
explicitly continue to work; we just stop steering beginners toward a
target the discovery command refuses to confirm.

Adds two tests:
- agent-skills must not appear anywhere in the unknown-target render.
- If the caller filters down to only agent-skills, the renderer must
  fall back to a sane default suggestion ("claude") rather than
  re-introducing agent-skills.

Closes #1208 (paired with #1197 which fixed the parser crash).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
danielmeppiel added a commit that referenced this pull request May 8, 2026
…tions (#1208) (#1215)

* fix(errors): hide agent-skills from unknown-target suggestions (#1208)

The unknown-target error renderer advertised every member of
CANONICAL_TARGETS as a recovery path, including the meta-target
"agent-skills". But "apm targets" intentionally omits "agent-skills"
from its table (it is a multi-harness fan-out target with no single
deploy_dir; visible only via "apm targets --json --all"). A user
following the error message's "apm targets  # see all supported
harnesses" hint cannot confirm "agent-skills" exists, leaving them
stuck on a contradiction APM authored itself.

Filter "agent-skills" out of the rendered "Valid targets:" CSV and
out of the "did you mean?" suggestion fallback chain. The canonical
set still ACCEPTS "agent-skills" via "--target agent-skills" and the
"target:"/"targets:" keys in apm.yml, so power users who pass it
explicitly continue to work; we just stop steering beginners toward a
target the discovery command refuses to confirm.

Adds two tests:
- agent-skills must not appear anywhere in the unknown-target render.
- If the caller filters down to only agent-skills, the renderer must
  fall back to a sane default suggestion ("claude") rather than
  re-introducing agent-skills.

Closes #1208 (paired with #1197 which fixed the parser crash).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(changelog): add entry for #1215

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(errors): avoid empty 'Valid targets:' when only meta-target visible

Address PR #1215 review: when the caller passes a list containing only
"agent-skills" (or empty), the rendered "Valid targets:" line previously
collapsed to a bare colon because the visible-set filter consumed all
entries. Compute the safety-net suggestion first and use it as the CSV
fallback so the line always carries a single actionable harness name.
Production call sites all pass the full canonical set, so this only
exercises in tests and hypothetical future callers; covers
defense-in-depth without changing behavior on the hot path.

Extends test_unknown_target_error_falls_back_when_only_meta_target_visible
to pin the new "Valid targets: claude" assertion alongside the existing
suggestion-fallback checks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(cli): align outdated help text with consistency test

PR #1216 added help="Check for outdated locked dependencies" to the
outdated command, but tests/unit/test_cli_consistency.py expects
"Show outdated locked dependencies" (the original docstring wording
the test was written against). The mismatch broke CI on main.

Restore the docstring wording so the help description matches the
test contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] YAML field target is not honored

2 participants