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
89 changes: 87 additions & 2 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,22 @@ Alias for [`apm install --mcp`](#apm-install---install-dependencies-and-deploy-l
apm mcp install NAME [OPTIONS] [-- COMMAND ARGV...]
```

**Arguments:**
- `NAME` - MCP server name. Use a registry name for registry installs, or a local name for self-defined stdio and remote servers.

**Options:**
- `--transport [stdio|http|sse|streamable-http]` - MCP transport. Inferred from `--url` or post-`--` argv when omitted.
- `--url URL` - MCP server URL for `http`, `sse`, or `streamable-http` transports.
- `--env KEY=VALUE` - Environment variable for stdio MCP servers. Repeatable.
- `--header KEY=VALUE` - HTTP header for remote MCP servers. Repeatable.
- `--mcp-version VER` - Pin a registry MCP entry to a specific version.
- `--registry URL` - Custom MCP registry URL for resolving `NAME`.
- `--dev` - Add the server to `devDependencies`.
- `--dry-run` - Show what would be added without writing.
- `--force` - Replace an existing MCP entry.
- `-v, --verbose` - Show detailed output.
- `--no-policy` - Skip org policy enforcement for this invocation.

**Examples:**
```bash
# stdio (post-`--` argv)
Expand Down Expand Up @@ -1709,7 +1725,7 @@ apm runtime remove [OPTIONS] {copilot|codex|llm|gemini}
- `{copilot|codex|llm|gemini}` - Runtime to remove

**Options:**
- `--yes` - Confirm the action without prompting
- `-y, --yes` - Confirm the action without prompting

#### `apm runtime status` - Show active runtime and preference order

Expand All @@ -1726,4 +1742,73 @@ apm runtime status

## Experimental Features

`apm experimental` manages opt-in flags that gate new or changing behaviour. Subcommands: `list`, `enable`, `disable`, `reset`. `apm experimental list` also supports `--json`, and `-v` / `--verbose` works on each subcommand. See the full reference in [Experimental Flags](../experimental/).
### `apm experimental` - Manage experimental feature flags

Manage opt-in flags that gate new or changing behaviour. Running `apm experimental` with no subcommand lists the available flags.

```bash
apm experimental [OPTIONS] COMMAND [ARGS]...
```

**Options:**
- `-v, --verbose` - Show verbose output

**Subcommands:**

| Command | Description |
|---------|-------------|
| `list` | List all experimental features |
| `enable NAME` | Enable an experimental feature |
| `disable NAME` | Disable an experimental feature |
| `reset [NAME]` | Reset one feature, or all features, to defaults |

#### `apm experimental list`

```bash
apm experimental list [OPTIONS]
```

**Options:**
- `--enabled` - Show only enabled features
- `--disabled` - Show only disabled features
- `--json` - Output as a JSON array
- `-v, --verbose` - Show detailed output

#### `apm experimental enable`

```bash
apm experimental enable NAME [OPTIONS]
```

**Arguments:**
- `NAME` - Experimental feature name

**Options:**
- `-v, --verbose` - Show verbose output

#### `apm experimental disable`

```bash
apm experimental disable NAME [OPTIONS]
```

**Arguments:**
- `NAME` - Experimental feature name

**Options:**
- `-v, --verbose` - Show verbose output

#### `apm experimental reset`

```bash
apm experimental reset [NAME] [OPTIONS]
```

**Arguments:**
- `NAME` - Optional experimental feature name. Omit to reset all feature overrides.

**Options:**
- `-y, --yes` - Skip the confirmation prompt when resetting all features
- `-v, --verbose` - Show verbose output

See the full reference in [Experimental Flags](../experimental/).
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Set `MCP_REGISTRY_URL` (default `https://api.mcp.github.com`) to point all `apm
|---------|---------|-----------|
| `apm runtime setup {copilot\|codex\|llm\|gemini}` | Install a runtime | `--version`, `--vanilla` |
| `apm runtime list` | Show installed runtimes | -- |
| `apm runtime remove {copilot\|codex\|llm\|gemini}` | Remove a runtime | `--yes` |
| `apm runtime remove {copilot\|codex\|llm\|gemini}` | Remove a runtime | `-y`, `--yes` |
| `apm runtime status` | Show active runtime | -- |

## Experimental features
Expand Down
1 change: 0 additions & 1 deletion src/apm_cli/commands/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ def _handle_unknown_flag(name: str, logger: CommandLogger) -> None:
@click.group(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good fix. Removing context_settings restores subcommand --help routing -- exactly the right call.

help="Manage experimental feature flags",
invoke_without_command=True,
context_settings={"allow_interspersed_args": True, "ignore_unknown_options": True},
)
@click.option("--verbose", "-v", is_flag=True, default=False, help="Show verbose output")
@click.pass_context
Expand Down
17 changes: 14 additions & 3 deletions src/apm_cli/commands/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,20 @@ def mcp():
" apm mcp install fetch -- npx -y @modelcontextprotocol/server-fetch\n\n"
" apm mcp install api --transport http --url https://example.com/mcp"
),
epilog=(
"Common options (see `apm install --mcp --help` for full list):\n"
" --transport [stdio|http|sse|streamable-http]\n"
" --url URL Server URL for remote transports\n"
" --env KEY=VALUE Environment variable (repeatable)\n"
" --header KEY=VALUE HTTP header (repeatable)\n"
" --registry URL Custom registry URL\n"
" --mcp-version VER Pin registry entry to a specific version\n"
" --dev / --dry-run / --force / --verbose / --no-policy\n"
),
)
@click.argument("name", required=True)
@click.pass_context
def mcp_install(ctx):
def mcp_install(ctx, name):
"""Forward all args to 'apm install --mcp ...'.

Examples:
Comment thread
shreejaykurhade marked this conversation as resolved.
Expand All @@ -106,9 +117,9 @@ def mcp_install(ctx):
_, post_dd = _split_argv_at_double_dash(_get_invocation_argv())
if post_dd:
pre_args = ctx.args[: len(ctx.args) - len(post_dd)]
forwarded = ["install", "--mcp", *pre_args, "--", *post_dd]
forwarded = ["install", "--mcp", name, *pre_args, "--", *post_dd]
else:
forwarded = ["install", "--mcp", *ctx.args]
forwarded = ["install", "--mcp", name, *ctx.args]

try:
cli.main(args=forwarded, standalone_mode=False)
Expand Down
2 changes: 1 addition & 1 deletion src/apm_cli/commands/outdated.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ def _check_one_dep(dep, downloader, verbose):
@click.option("--parallel-checks", "-j", type=int, default=4,
help="Max concurrent remote checks (default: 4, 0 = sequential)")
def outdated(global_, verbose, parallel_checks):
"""Show outdated locked dependencies.
"""Show outdated locked dependencies

Compares each locked dependency against the remote to detect staleness.
Tag-pinned deps use semver comparison; branch-pinned deps compare commit SHAs.
Expand Down
14 changes: 7 additions & 7 deletions src/apm_cli/commands/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,25 @@
"fmt",
type=click.Choice(["apm", "plugin"]),
default="apm",
help="Bundle format.",
help="Bundle format",
)
@click.option(
"--target",
"-t",
type=TargetParamType(),
default=None,
help="Target platform (comma-separated for multiple, e.g. claude,copilot). Use 'all' for every target. Auto-detects if not specified.",
help="Target platform (comma-separated for multiple, e.g. claude,copilot). Use 'all' for every target. Auto-detects if not specified",
)
@click.option("--archive", is_flag=True, default=False, help="Produce a .tar.gz archive.")
@click.option("--archive", is_flag=True, default=False, help="Produce a .tar.gz archive")
@click.option(
"-o",
"--output",
type=click.Path(),
default="./build",
help="Output directory (default: ./build).",
help="Output directory (default: ./build)",
)
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be packed without writing.")
@click.option("--force", is_flag=True, default=False, help="On collision, last writer wins.")
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be packed without writing")
Comment thread
shreejaykurhade marked this conversation as resolved.
@click.option("--force", is_flag=True, default=False, help="On collision, last writer wins")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed packing information")
@click.pass_context
def pack_cmd(ctx, fmt, target, archive, output, dry_run, force, verbose):
Expand Down Expand Up @@ -107,7 +107,7 @@ def pack_cmd(ctx, fmt, target, archive, output, dry_run, force, verbose):
help="Target directory (default: current directory).",
)
@click.option("--skip-verify", is_flag=True, default=False, help="Skip bundle completeness check.")
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be unpacked without writing.")
@click.option("--dry-run", is_flag=True, default=False, help="Show what would be unpacked without writing")
@click.option("--force", is_flag=True, default=False, help="Deploy despite critical hidden-character findings.")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed unpacking information")
@click.pass_context
Expand Down
2 changes: 1 addition & 1 deletion src/apm_cli/commands/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def list():

@runtime.command(help="Remove an installed runtime")
@click.argument("runtime_name", type=click.Choice(["copilot", "codex", "llm", "gemini"]))
@click.confirmation_option(prompt="Are you sure you want to remove this runtime?", help="Confirm the action without prompting")
@click.confirmation_option("--yes", "-y", prompt="Are you sure you want to remove this runtime?", help="Confirm the action without prompting")
def remove(runtime_name):
Comment on lines 125 to 128

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

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

This change adds the -y alias for apm runtime remove, but the apm-guide CLI reference used by the apm-usage skill still lists apm runtime remove with only --yes in its key flags table. Please update packages/apm-guide/.apm/skills/apm-usage/commands.md accordingly so the generated guidance stays consistent with the CLI.

Copilot uses AI. Check for mistakes.
Comment on lines +127 to 128

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: update packages/apm-guide/.apm/skills/apm-usage/commands.md to include the -y alias so generated guidance stays consistent with the CLI. (Also flagged by Copilot.)

"""Remove an installed runtime from APM management."""
logger = CommandLogger("runtime remove")
Expand Down
8 changes: 3 additions & 5 deletions src/apm_cli/output/script_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
from rich.console import Console
from rich.text import Text
from rich.panel import Panel
from rich.tree import Tree
from rich import box
RICH_AVAILABLE = True
except ImportError:
RICH_AVAILABLE = False
Expand Down Expand Up @@ -40,9 +38,9 @@ def format_script_header(self, script_name: str, params: Dict[str, str]) -> List

# Main header
if self.use_color:
lines.append(self._styled(f" Running script: {script_name}", "cyan bold"))
lines.append(self._styled(f"[>] Running script: {script_name}", "cyan bold"))
else:
lines.append(f" Running script: {script_name}")
lines.append(f"[>] Running script: {script_name}")

# Parameters tree if any exist
if params:
Expand Down Expand Up @@ -344,4 +342,4 @@ def _styled(self, text: str, style: str) -> str:
self.console.print(styled_text, end="")
return capture.get()
else:
return text
return text
106 changes: 106 additions & 0 deletions tests/unit/test_cli_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Regression tests for CLI help and output consistency."""

from unittest.mock import patch

from click.testing import CliRunner

from apm_cli.cli import cli
from apm_cli.output.script_formatters import ScriptExecutionFormatter


def test_experimental_subcommand_help_is_specific():
runner = CliRunner()

list_result = runner.invoke(cli, ["experimental", "list", "--help"])
assert list_result.exit_code == 0
assert "Usage: cli experimental list [OPTIONS]" in list_result.output
assert "--enabled" in list_result.output
assert "--disabled" in list_result.output
assert "--json" in list_result.output

enable_result = runner.invoke(cli, ["experimental", "enable", "--help"])
assert enable_result.exit_code == 0
assert "Usage: cli experimental enable [OPTIONS] NAME" in enable_result.output

disable_result = runner.invoke(cli, ["experimental", "disable", "--help"])
assert disable_result.exit_code == 0
assert "Usage: cli experimental disable [OPTIONS] NAME" in disable_result.output

reset_result = runner.invoke(cli, ["experimental", "reset", "--help"])
assert reset_result.exit_code == 0
assert "Usage: cli experimental reset [OPTIONS] [NAME]" in reset_result.output
assert "-y, --yes" in reset_result.output


def test_runtime_remove_help_includes_short_yes_alias():
result = CliRunner().invoke(cli, ["runtime", "remove", "--help"])

assert result.exit_code == 0
assert "-y, --yes" in result.output


def test_mcp_install_forwards_unknown_options_before_double_dash():
runner = CliRunner()

with runner.isolated_filesystem(), patch(
"apm_cli.commands.install._get_invocation_argv",
return_value=[
"apm",
"mcp",
"install",
"myserver",
"--target",
"cursor",
"--dry-run",
"--",
"npx",
"-y",
"pkg",
],
):
result = runner.invoke(
cli,
[
"mcp",
"install",
"myserver",
"--target",
"cursor",
"--dry-run",
"--",
"npx",
"-y",
"pkg",
],
)

assert result.exit_code == 0
assert "would add MCP server 'myserver'" in result.output


def test_pack_unpack_dry_run_help_has_no_trailing_period():
runner = CliRunner()

pack_result = runner.invoke(cli, ["pack", "--help"])
assert pack_result.exit_code == 0
assert "Show what would be packed without writing." not in pack_result.output
assert "Show what would be packed without writing" in pack_result.output

unpack_result = runner.invoke(cli, ["unpack", "--help"])
assert unpack_result.exit_code == 0
assert "Show what would be unpacked without writing." not in unpack_result.output
assert "Show what would be unpacked without writing" in unpack_result.output


def test_outdated_top_level_help_description_has_no_trailing_period():
result = CliRunner().invoke(cli, ["--help"])

assert result.exit_code == 0
assert "Show outdated locked dependencies." not in result.output
assert "Show outdated locked dependencies" in result.output

Comment on lines +98 to +101

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this assertion is fragile. Checking exact column spacing ("outdated Show...") will break if Click changes column widths. Consider asserting just the substring without padding.


def test_script_run_header_uses_running_status_symbol():
formatter = ScriptExecutionFormatter(use_color=False)

assert formatter.format_script_header("build", {})[0] == "[>] Running script: build"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Well-targeted regression tests that lock the --help contract. Good addition.

7 changes: 7 additions & 0 deletions tests/unit/test_mcp_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,13 @@ def test_help_shows_alias_message_and_example(self):
assert result.exit_code == 0
assert "Alias for 'apm install --mcp'" in result.output
assert "apm mcp install fetch" in result.output
assert "Usage: mcp install [OPTIONS] NAME" in result.output
assert "--transport" in result.output
assert "--url" in result.output
assert "--env" in result.output
assert "--header" in result.output
assert "--mcp-version" in result.output
assert "--registry" in result.output

def test_forwards_args_to_root_install_with_mcp_flag(self):
"""Verify the alias invokes the root `cli` with `install --mcp <argv>`."""
Expand Down
Loading