Skip to content

Commit e62c1ed

Browse files
feat(cli): promote 'marketplace doctor' to top-level 'apm doctor' (+ workflow discoverability) (#1539)
* feat(cli): promote 'marketplace doctor' to top-level 'apm doctor' (#1537) Closes one of the four asks in #1537 by promoting the diagnostics verb to a top-level command and adding small discoverability hooks around the existing lifecycle commands. Tier 1 (this PR): * Promote 'apm marketplace doctor' to 'apm doctor'. The legacy invocation keeps working as a hidden deprecated alias that prints a one-line migration hint before delegating. * Add a 'Common workflows' epilog to 'apm --help' so init -> install -> outdated -> update -> doctor and the 'install --frozen && audit --ci' CI idiom are discoverable from the root help. * Add contextual error tips: AuthenticationError now points to 'apm doctor'; FrozenInstallError points to 'apm outdated' + 'apm update'. Five touch sites, one-line nudges each. * New Rosetta Stone guide 'Operating installed context' mapping npm/pnpm/uv/cargo/brew vocabulary to existing apm verbs (deps why, deps tree, outdated, install --frozen, audit --ci, doctor). Tier 2 deferred: 'apm status' summary view -- waits for a concrete shape that isn't already covered by deps tree + outdated + targets. Tier 3 declined: 'apm sync', 'apm sync --update', and 'apm check' are exact duplicates of 'install --frozen', 'update', and 'audit --ci' respectively (the 'check' name also collides with 'apm marketplace check'). Documented in the issue reply. The arch-invariant budget for commands/install.py is raised 2010 -> 2012 with justification matching the lockfile-UX precedent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): address copilot-pull-request-reviewer feedback on #1539 Folds 7 inline review comments: * tests/unit/install/test_architecture_invariants.py: correct skill path .github -> .apm/skills/python-architecture/SKILL.md (the pre-existing message pointed to a non-existent location). * tests/unit/commands/test_doctor.py: the deprecation-hint assertion now checks both stdout and stderr so it does not depend on Click 8.2's stream-separation default. * src/apm_cli/cli.py: reformat _CLI_EPILOG so the per-line layout survives Click's epilog rewrapping. Keeps the documented Click '\b' formatting marker (already used in src/apm_cli/commands/view.py lines 422 and 425) which is a CLI rendering directive, not user-facing output. * src/apm_cli/commands/install.py, src/apm_cli/commands/update.py: pass symbol='info' on the new _rich_info() tip lines so they render with the standard [i] prefix and match neighbouring usage. * docs/src/content/docs/guides/operating-installed-context.md: document 'apm targets' as the default invocation; mention '--json --all' for the agent-skills meta-target. Arch budget for install.py raised 2012 -> 2014 to absorb ruff's multi-line reformat of the now-keyword-arg _rich_info() call. Justification kept inline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ec771e5 commit e62c1ed

9 files changed

Lines changed: 286 additions & 12 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
title: "Operating installed context"
3+
description: "Day-to-day workflow for APM-managed projects: reproduce the lockfile in CI, see what is installed, diagnose environment problems. Maps every common operational question to the existing command."
4+
sidebar:
5+
order: 7
6+
---
7+
8+
After `apm install` succeeds, the day-to-day operating questions are: what
9+
is installed, has anything drifted from the lockfile, and why is the
10+
environment broken when something fails. APM ships a command for each
11+
question -- this page maps the question to the command so you do not have
12+
to remember the flag matrix.
13+
14+
## At a glance
15+
16+
| You want to... | Run | Notes |
17+
|---|---|---|
18+
| Reproduce the lockfile exactly (CI gate) | `apm install --frozen` | Refuses to install when `apm.lock.yaml` is missing or out of sync with `apm.yml`. Equivalent in spirit to `npm ci` / `pnpm install --frozen-lockfile`. |
19+
| Refresh refs and rewrite the lockfile | `apm update` (or `apm update --yes` for CI) | Restructures the dependency graph against latest matching refs. |
20+
| Validate lockfile integrity for CI | `apm audit --ci` | Lockfile-consistency check + on-disk integrity. Pair with `--format sarif --output audit.sarif` for GitHub Code Scanning. |
21+
| See what is installed | `apm deps list` | Project scope. Add `--global` for `~/.apm/`. |
22+
| Inspect the dependency tree | `apm deps tree` | Hierarchical view of direct + transitive deps. |
23+
| Find out why a package is installed | `apm deps why <package>` | Reverse lookup -- "who pulled this in?". Add `--json` for scripts. |
24+
| See what is outdated | `apm outdated` | Locked refs vs latest matching upstream. |
25+
| Diagnose a broken environment | `apm doctor` | Aggregated pass/fail table: git, network, auth, gh CLI, and (if present) marketplace config. |
26+
| Inspect the cache | `apm cache info` | Disk usage and location. `apm cache clean` removes everything; `apm cache prune --days N` is incremental. |
27+
| Inspect resolved runtimes | `apm runtime status` | Active runtime and preference order. |
28+
| Inspect resolved targets | `apm targets` | Which harnesses APM will deploy to. Add `--json --all` to include meta-targets (e.g. `agent-skills`). |
29+
| Show package metadata | `apm view <package>` | Versions, refs, owner, declared scripts. |
30+
31+
## Recommended CI block
32+
33+
```yaml
34+
- run: apm install --frozen
35+
- run: apm audit --ci --format sarif --output apm-audit.sarif
36+
```
37+
38+
The `--frozen` flag is the CI-safety primitive: if the lockfile is missing
39+
or has drifted from `apm.yml`, the install fails before producing any
40+
artifacts. `apm audit --ci` is then the integrity gate -- it validates that
41+
the locked content matches what was actually fetched.
42+
43+
## Local refresh loop
44+
45+
```bash
46+
apm update # refresh refs + rewrite the lockfile
47+
apm install # materialize the new lockfile
48+
apm audit # confirm integrity
49+
```
50+
51+
Use this when you intentionally want to take newer upstream refs. The
52+
lockfile change is the auditable record of the upgrade.
53+
54+
## When something is broken
55+
56+
The first stop for "I installed but it does not work" or "CI passes
57+
locally but fails on the runner" is `apm doctor`. It runs a bounded set of
58+
environment checks (git on PATH, github.com reachable, auth token
59+
detected, gh CLI present, optionally marketplace config) and renders a
60+
pass/fail table with a single non-zero exit code if a critical check
61+
fails.
62+
63+
```bash
64+
apm doctor # quick pass/fail table
65+
apm doctor --verbose # plus detail per check
66+
```
67+
68+
For more targeted introspection:
69+
70+
- `apm cache info` -- is the cache writable, how large is it
71+
- `apm runtime status` -- is the expected runtime installed
72+
- `apm config` -- is the configuration parseable, what is active
73+
- `APM_DEBUG=1 apm install --dry-run -v` -- full resolution trace
74+
75+
## Vocabulary mapping for users coming from other ecosystems
76+
77+
If you reach for a verb from another package manager and APM does not
78+
have it, the equivalent is almost always already in the table above.
79+
Common translations:
80+
81+
| Other ecosystem | APM equivalent |
82+
|---|---|
83+
| `npm ci` | `apm install --frozen` |
84+
| `npm audit` | `apm audit` |
85+
| `npm why <pkg>` / `yarn why <pkg>` | `apm deps why <pkg>` |
86+
| `pnpm install --frozen-lockfile` | `apm install --frozen` |
87+
| `uv sync` | `apm install` (or `apm install --frozen` for the CI form) |
88+
| `uv lock --check` | `apm audit --ci` |
89+
| `cargo tree -i <pkg>` | `apm deps why <pkg>` |
90+
| `brew doctor` / `flutter doctor` | `apm doctor` |
91+
| `pip check` | `apm audit` |

src/apm_cli/cli.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from apm_cli.commands.compile import compile as compile_cmd
2323
from apm_cli.commands.config import config
2424
from apm_cli.commands.deps import deps
25+
from apm_cli.commands.doctor import doctor
2526
from apm_cli.commands.experimental import experimental
2627
from apm_cli.commands.init import init
2728
from apm_cli.commands.install import install
@@ -43,8 +44,24 @@
4344
from apm_cli.commands.update import update
4445
from apm_cli.commands.view import view as view_cmd
4546

47+
_CLI_EPILOG = (
48+
"\b\n"
49+
"Common workflows:\n"
50+
" apm init Scaffold a new project\n"
51+
" apm install Install dependencies from apm.yml\n"
52+
" apm install --frozen Reproduce lockfile exactly (CI-safe)\n"
53+
" apm outdated See what's drifted from upstream\n"
54+
" apm update Refresh refs and rewrite the lockfile\n"
55+
" apm audit --ci Validate lockfile integrity for CI gates\n"
56+
" apm doctor Diagnose environment problems\n"
57+
" apm run <script> Execute a script from apm.yml"
58+
)
59+
4660

47-
@click.group(help="Agent Package Manager (APM): The package manager for AI-Native Development")
61+
@click.group(
62+
help="Agent Package Manager (APM): The package manager for AI-Native Development",
63+
epilog=_CLI_EPILOG,
64+
)
4865
@click.option(
4966
"--version",
5067
is_flag=True,
@@ -107,6 +124,7 @@ def cli(ctx):
107124
cli.add_command(mcp)
108125
cli.add_command(policy)
109126
cli.add_command(outdated_cmd, name="outdated")
127+
cli.add_command(doctor)
110128
cli.add_command(marketplace)
111129
cli.add_command(marketplace_search, name="search")
112130

src/apm_cli/commands/doctor.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""``apm doctor`` top-level command.
2+
3+
Thin Click wrapper around :func:`apm_cli.commands.marketplace.doctor.run_doctor`.
4+
The diagnostics are owned by the marketplace doctor module today because that
5+
is where the existing implementation lives; promoting the entry point to the
6+
top level is a discoverability fix without scope expansion. Future PRs may
7+
add additional domains (lockfile, cache, runtime, config) by extending
8+
``run_doctor`` -- each behind its own scope justification.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import sys
14+
15+
import click
16+
17+
from .marketplace.doctor import run_doctor
18+
19+
20+
@click.command(
21+
help=(
22+
"Run environment diagnostics (git, network, auth, gh CLI, "
23+
"marketplace config). Reports a pass/fail table and exits non-zero "
24+
"if a critical check fails."
25+
)
26+
)
27+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
28+
def doctor(verbose):
29+
"""Top-level diagnostic entry point."""
30+
exit_code = run_doctor(verbose, logger_name="doctor")
31+
if exit_code != 0:
32+
sys.exit(exit_code)

src/apm_cli/commands/install.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,6 +1544,7 @@ def install( # noqa: PLR0913
15441544
_rich_error(str(e))
15451545
if e.diagnostic_context:
15461546
_rich_echo(e.diagnostic_context)
1547+
_rich_info("Tip: run 'apm doctor' to diagnose auth and connectivity.", symbol="info")
15471548
sys.exit(1)
15481549
except DirectDependencyError as e:
15491550
_maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger)
@@ -1750,12 +1751,17 @@ def _install_apm_packages(ctx, outcome):
17501751
_rich_error(str(e))
17511752
if e.diagnostic_context:
17521753
_rich_echo(e.diagnostic_context)
1754+
_rich_info("Tip: run 'apm doctor' to diagnose auth and connectivity.", symbol="info")
17531755
sys.exit(1)
17541756
except FrozenInstallError as e:
17551757
_maybe_rollback_manifest(ctx.snapshot_manifest_path, ctx.manifest_snapshot, logger)
17561758
_rich_error(str(e))
17571759
for reason in e.reasons:
17581760
_rich_echo(reason)
1761+
_rich_info(
1762+
"Tip: run 'apm outdated' to see what changed, then 'apm update'.",
1763+
symbol="info",
1764+
)
17591765
sys.exit(1)
17601766
except Exception as e:
17611767
_maybe_rollback_manifest(ctx.snapshot_manifest_path, ctx.manifest_snapshot, logger)

src/apm_cli/commands/marketplace/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ class MarketplaceGroup(click.Group):
8484
"init",
8585
"check",
8686
"outdated",
87-
"doctor",
8887
"publish",
8988
"package",
9089
"migrate",

src/apm_cli/commands/marketplace/doctor.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""``apm marketplace doctor`` command."""
1+
"""``apm doctor`` (and legacy ``apm marketplace doctor``) command implementation."""
22

33
from __future__ import annotations
44

@@ -25,11 +25,14 @@
2525
)
2626

2727

28-
@marketplace.command(help="Run environment diagnostics for marketplace publishing")
29-
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
30-
def doctor(verbose):
31-
"""Check git, network, auth, and marketplace config readiness."""
32-
logger = CommandLogger("marketplace-doctor", verbose=verbose)
28+
def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int:
29+
"""Execute the doctor diagnostics and return an exit code.
30+
31+
Shared between the top-level ``apm doctor`` command and the legacy
32+
``apm marketplace doctor`` alias so both surfaces produce identical
33+
output. Returns ``0`` if all critical checks pass, ``1`` otherwise.
34+
"""
35+
logger = CommandLogger(logger_name, verbose=verbose)
3336
checks = []
3437

3538
# Check 1: git on PATH
@@ -275,4 +278,28 @@ def doctor(verbose):
275278
# Exit: 0 if checks 1-2 pass; config checks are informational
276279
critical_checks = [c for c in checks if not c.informational]
277280
if any(not c.passed for c in critical_checks):
278-
sys.exit(1)
281+
return 1
282+
return 0
283+
284+
285+
@marketplace.command(
286+
name="doctor",
287+
help="DEPRECATED: use 'apm doctor' instead. Run environment diagnostics.",
288+
hidden=True,
289+
)
290+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
291+
def doctor(verbose):
292+
"""Deprecated alias for ``apm doctor``.
293+
294+
Prints a one-line deprecation hint and forwards to :func:`run_doctor`.
295+
The command stays functional for one release to give CI pipelines and
296+
scripts time to migrate; it is hidden from ``apm marketplace --help``
297+
so new users discover the top-level form.
298+
"""
299+
click.echo(
300+
"[!] 'apm marketplace doctor' is deprecated; use 'apm doctor' instead.",
301+
err=True,
302+
)
303+
exit_code = run_doctor(verbose, logger_name="marketplace-doctor")
304+
if exit_code != 0:
305+
sys.exit(exit_code)

src/apm_cli/commands/update.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,11 +321,16 @@ def _plan_callback(plan: UpdatePlan) -> bool:
321321
_rich_error(str(e))
322322
for reason in e.reasons:
323323
_rich_echo(reason)
324+
_rich_info(
325+
"Tip: run 'apm outdated' to see what changed, then 'apm update'.",
326+
symbol="info",
327+
)
324328
sys.exit(1)
325329
except AuthenticationError as e:
326330
_rich_error(str(e))
327331
if e.diagnostic_context:
328332
_rich_echo(e.diagnostic_context)
333+
_rich_info("Tip: run 'apm doctor' to diagnose auth and connectivity.", symbol="info")
329334
sys.exit(1)
330335
except (DirectDependencyError, PolicyViolationError) as e:
331336
_rich_error(str(e))

tests/unit/commands/test_doctor.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Tests for the top-level ``apm doctor`` command and its deprecated alias."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import patch
6+
7+
import pytest
8+
from click.testing import CliRunner
9+
10+
from apm_cli.cli import cli
11+
from apm_cli.commands.marketplace import marketplace
12+
13+
# Token env vars that AuthResolver inspects. Cleared so the doctor's auth
14+
# check is deterministic regardless of the host environment.
15+
_TOKEN_ENV_VARS = ("GITHUB_APM_PAT", "GITHUB_TOKEN", "GH_TOKEN")
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def _clear_token_env(monkeypatch):
20+
for var in _TOKEN_ENV_VARS:
21+
monkeypatch.delenv(var, raising=False)
22+
23+
24+
@pytest.fixture
25+
def mock_subprocess_success():
26+
"""Stub git/gh subprocess calls to deterministic success."""
27+
with patch("apm_cli.commands.marketplace.doctor.subprocess.run") as run:
28+
run.return_value.returncode = 0
29+
run.return_value.stdout = "git version 2.42.0"
30+
run.return_value.stderr = ""
31+
yield run
32+
33+
34+
def test_apm_doctor_registered_at_top_level():
35+
"""`apm doctor --help` must succeed -- it is the discoverability fix."""
36+
runner = CliRunner()
37+
result = runner.invoke(cli, ["doctor", "--help"])
38+
assert result.exit_code == 0
39+
assert "environment diagnostics" in result.output.lower()
40+
41+
42+
def test_apm_doctor_appears_in_root_help():
43+
"""`apm --help` must list `doctor` so users can discover it."""
44+
runner = CliRunner()
45+
result = runner.invoke(cli, ["--help"])
46+
assert result.exit_code == 0
47+
assert "doctor" in result.output
48+
49+
50+
def test_common_workflows_footer_present():
51+
"""`apm --help` epilog must surface the common-workflows hint."""
52+
runner = CliRunner()
53+
result = runner.invoke(cli, ["--help"])
54+
assert result.exit_code == 0
55+
assert "Common workflows" in result.output
56+
assert "apm install --frozen" in result.output
57+
assert "apm doctor" in result.output
58+
59+
60+
def test_marketplace_doctor_hidden_from_help():
61+
"""Legacy `apm marketplace doctor` must not appear in marketplace --help."""
62+
runner = CliRunner()
63+
result = runner.invoke(cli, ["marketplace", "--help"])
64+
assert result.exit_code == 0
65+
# 'doctor' as a subcommand listing should be gone from the Authoring
66+
# commands block now that it has been promoted to top-level.
67+
assert "doctor " not in result.output # column-aligned listing
68+
69+
70+
def test_marketplace_doctor_still_works_with_deprecation_hint(mock_subprocess_success):
71+
"""Legacy invocation must keep working and print the migration hint."""
72+
runner = CliRunner()
73+
result = runner.invoke(marketplace, ["doctor"])
74+
# The deprecation hint is emitted with err=True. Click 8.2 separates
75+
# stdout/stderr by default, so check both to stay version-agnostic.
76+
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
77+
assert "deprecated" in combined.lower()
78+
assert "apm doctor" in combined
79+
# And the diagnostics still run.
80+
assert result.exit_code in (0, 1) # 1 if network unreachable in sandbox
81+
82+
83+
def test_apm_doctor_runs_diagnostics(mock_subprocess_success):
84+
"""Top-level invocation should produce the diagnostics table."""
85+
runner = CliRunner()
86+
result = runner.invoke(cli, ["doctor"])
87+
# Network check may legitimately fail in sandboxed test env -> non-zero ok.
88+
assert result.exit_code in (0, 1)
89+
assert "git" in result.output.lower()

tests/unit/install/test_architecture_invariants.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,20 @@ def test_install_py_under_legacy_budget():
185185
by the new ``apm update`` command and CI-safe install flow. All
186186
additions are entry-point glue at the Click handler boundary; the
187187
actual logic lives in ``apm_cli/install/`` (plan, errors, service).
188+
189+
Issue #1537 (sync/check/status/doctor workflow) raised 2010 -> 2014
190+
to add two contextual error-hint blocks: an ``apm doctor`` tip on
191+
``AuthenticationError`` and an ``apm outdated`` -> ``apm update``
192+
tip on ``FrozenInstallError``. Both are entry-point glue (one
193+
``_rich_info(..., symbol="info")`` call per handler, expanded to
194+
multiple lines by ruff's formatter) -- no new logic.
188195
"""
189196
install_py = Path(__file__).resolve().parents[3] / "src" / "apm_cli" / "commands" / "install.py"
190197
assert install_py.is_file()
191198
n = _line_count(install_py)
192-
assert n <= 2010, (
193-
f"commands/install.py grew to {n} LOC (budget 2010). "
199+
assert n <= 2014, (
200+
f"commands/install.py grew to {n} LOC (budget 2014). "
194201
"Do NOT trim cosmetically -- engage the python-architecture skill "
195-
"(.github/skills/python-architecture/SKILL.md) and propose an "
202+
"(.apm/skills/python-architecture/SKILL.md) and propose an "
196203
"extraction into apm_cli/install/."
197204
)

0 commit comments

Comments
 (0)