diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 35ad3ee9c..896f54d6a 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -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) @@ -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 @@ -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/). diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 32198b1e2..b28877a05 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -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 diff --git a/src/apm_cli/commands/experimental.py b/src/apm_cli/commands/experimental.py index 12905e44b..dfec8f5a6 100644 --- a/src/apm_cli/commands/experimental.py +++ b/src/apm_cli/commands/experimental.py @@ -140,7 +140,6 @@ def _handle_unknown_flag(name: str, logger: CommandLogger) -> None: @click.group( 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 diff --git a/src/apm_cli/commands/mcp.py b/src/apm_cli/commands/mcp.py index 811b90eb2..d7ef6b050 100644 --- a/src/apm_cli/commands/mcp.py +++ b/src/apm_cli/commands/mcp.py @@ -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: @@ -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) diff --git a/src/apm_cli/commands/outdated.py b/src/apm_cli/commands/outdated.py index b34739d0f..13c6781f2 100644 --- a/src/apm_cli/commands/outdated.py +++ b/src/apm_cli/commands/outdated.py @@ -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. diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index d32eb4c08..5b40d072f 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -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") +@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): @@ -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 diff --git a/src/apm_cli/commands/runtime.py b/src/apm_cli/commands/runtime.py index 74b327ea5..22dbaa7e9 100644 --- a/src/apm_cli/commands/runtime.py +++ b/src/apm_cli/commands/runtime.py @@ -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): """Remove an installed runtime from APM management.""" logger = CommandLogger("runtime remove") diff --git a/src/apm_cli/output/script_formatters.py b/src/apm_cli/output/script_formatters.py index d73de659c..2c42bf34d 100644 --- a/src/apm_cli/output/script_formatters.py +++ b/src/apm_cli/output/script_formatters.py @@ -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 @@ -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: @@ -344,4 +342,4 @@ def _styled(self, text: str, style: str) -> str: self.console.print(styled_text, end="") return capture.get() else: - return text \ No newline at end of file + return text diff --git a/tests/unit/test_cli_consistency.py b/tests/unit/test_cli_consistency.py new file mode 100644 index 000000000..fe3c4e0f8 --- /dev/null +++ b/tests/unit/test_cli_consistency.py @@ -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 + + +def test_script_run_header_uses_running_status_symbol(): + formatter = ScriptExecutionFormatter(use_color=False) + + assert formatter.format_script_header("build", {})[0] == "[>] Running script: build" diff --git a/tests/unit/test_mcp_command.py b/tests/unit/test_mcp_command.py index 45da4aacd..926afa0c1 100644 --- a/tests/unit/test_mcp_command.py +++ b/tests/unit/test_mcp_command.py @@ -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 `."""