Skip to content

feat(cli): new CLI with serve and api commands#146

Merged
S1M0N38 merged 9 commits intomainfrom
cli
Feb 2, 2026
Merged

feat(cli): new CLI with serve and api commands#146
S1M0N38 merged 9 commits intomainfrom
cli

Conversation

@S1M0N38
Copy link
Collaborator

@S1M0N38 S1M0N38 commented Feb 1, 2026

Summary

Refactors the CLI from argparse to Typer and adds comprehensive integration tests.

CLI Changes

  • balatrobot serve - Start Balatro with BalatroBot mod (previously bare balatrobot)
  • balatrobot api <method> [params] - Call API endpoints on a running server

Usage Examples

# Start server
balatrobot serve --fast --headless

# Call API endpoints
balatrobot api health
balatrobot api gamestate
balatrobot api start '{"deck": "RED", "stake": "WHITE"}'

New Test Files

File Tests Description
tests/cli/test_client.py 7 BalatroClient.call() - success, API errors, connection errors
tests/cli/test_api_cmd.py 11 api command - method validation, JSON params, error formatting
tests/cli/test_serve_cmd.py 9 serve command - platform validation, Config.from_kwargs()

Infrastructure

  • tests/cli/conftest.py: Balatro instance fixtures (parallel instances per worker)
  • Makefile: CLI tests now run with -n $(XDIST_WORKERS) for parallel execution

Test Results

  • 65 passed, 7 skipped (platform-specific)
  • All quality checks pass (lint, typecheck)

Breaking Change

balatrobot alone now shows help. Use balatrobot serve to start the server.

Add comprehensive integration tests for the new Typer-based CLI:

- tests/cli/test_client.py: BalatroClient.call() tests
  - Success paths (health, gamestate)
  - API error handling (INVALID_STATE, BAD_REQUEST)
  - Connection error handling
  - Request ID incrementing
  - URL property formatting

- tests/cli/test_api_cmd.py: 'balatrobot api' command tests
  - Happy path with various methods
  - Method enum validation (invalid method → exit 2)
  - JSON param validation (invalid JSON → exit 1)
  - API error formatting
  - Connection error formatting
  - Output format (indented JSON)

- tests/cli/test_serve_cmd.py: 'balatrobot serve' command tests
  - Platform validation
  - Help text verification
  - Config.from_kwargs() env var fallback
  - Main app structure

Infrastructure changes:
- tests/cli/conftest.py: Add Balatro instance fixtures following
  tests/lua pattern (parallel instances, per-worker ports)
- Makefile: Run CLI tests with parallel workers (-n flag)
Copilot AI review requested due to automatic review settings February 1, 2026 23:37
Copy link
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 PR refactors the CLI from argparse to Typer and adds comprehensive integration tests for the new CLI commands. The refactoring introduces a subcommand-based interface (balatrobot serve and balatrobot api) with enhanced test coverage.

Changes:

  • Replaced argparse-based CLI with Typer framework, introducing subcommands serve and api
  • Added Config.from_kwargs() method to support the new CLI parameter handling
  • Implemented comprehensive integration tests with parallel test execution support
  • Exported BalatroClient and APIError in the public API for programmatic use

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pyproject.toml Added typer>=0.15 dependency
uv.lock Updated lock file with typer and transitive dependencies (rich, shellingham, click)
src/balatrobot/init.py Exported APIError and BalatroClient to public API
src/balatrobot/cli.py Removed old argparse-based CLI implementation
src/balatrobot/cli/init.py New Typer app entry point with no_args_is_help
src/balatrobot/cli/serve.py New serve command with platform validation and config building
src/balatrobot/cli/client.py Synchronous JSON-RPC 2.0 client for API calls
src/balatrobot/cli/api.py New api command with Method enum and error handling
src/balatrobot/config.py Added from_kwargs() class method for CLI parameter handling
tests/cli/conftest.py Enhanced with parallel Balatro instance management and fixtures
tests/cli/test_serve_cmd.py Tests for serve command options and validation
tests/cli/test_client.py Integration tests for BalatroClient
tests/cli/test_api_cmd.py Integration tests for api command
Makefile Updated CLI tests to run with parallel workers

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"""Running without args shows help (exit code 0 per Typer no_args_is_help)."""
result = runner.invoke(app, [])
# Typer no_args_is_help shows help and exits with code 0
assert result.exit_code == 0 or result.exit_code == 2
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The test case test_no_args_shows_help checks for exit code 0 or 2, which is inconsistent. When Typer's no_args_is_help=True is set, running the command without arguments should consistently exit with code 0. The condition result.exit_code == 0 or result.exit_code == 2 suggests uncertainty about the behavior, but Typer with no_args_is_help=True always exits with code 0 when showing help.

Suggested change
assert result.exit_code == 0 or result.exit_code == 2
assert result.exit_code == 0

Copilot uses AI. Check for mistakes.
Comment on lines 60 to 62
except Exception as e:
typer.echo(f"Error: Connection failed - {e}", err=True)
raise typer.Exit(code=1)
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The broad exception handler catches all exceptions including system exceptions. Consider being more specific about connection-related exceptions (e.g., httpx.ConnectError, httpx.TimeoutException) to avoid masking unexpected errors or system signals like KeyboardInterrupt.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +56
raise APIError(
name=error["data"]["name"],
message=error["message"],
code=error["code"],
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The error parsing assumes the error response has a nested structure error["data"]["name"], but this may fail with a KeyError if the API returns an error in a different format or if the "data" or "name" keys are missing. Consider adding defensive checks or using .get() with appropriate defaults.

Suggested change
raise APIError(
name=error["data"]["name"],
message=error["message"],
code=error["code"],
error_data = error.get("data") if isinstance(error, dict) else None
if isinstance(error_data, dict):
name = error_data.get("name") or "UnknownError"
else:
name = "UnknownError"
message = error.get("message", "Unknown error")
code = error.get("code", -1)
raise APIError(
name=name,
message=message,
code=code,

Copilot uses AI. Check for mistakes.
name=error["data"]["name"],
message=error["message"],
code=error["code"],
)
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The code assumes data["result"] exists when there's no error, but if the API returns a malformed response without a "result" field, this will raise a KeyError. Consider adding validation to ensure the response contains the expected "result" field.

Suggested change
)
)
if "result" not in data:
raise APIError(
name="InvalidResponse",
message="Response missing 'result' field",
code=-1,
)

Copilot uses AI. Check for mistakes.
try:
asyncio.run(_serve(config))
except KeyboardInterrupt:
pass
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
pass
typer.echo("\nShutting down server...")
raise typer.Exit(code=0)

Copilot uses AI. Check for mistakes.
@S1M0N38 S1M0N38 changed the title feat(cli): Typer-based CLI with integration tests feat(cli): new CLI with serve and api commands Feb 2, 2026
@S1M0N38 S1M0N38 requested a review from Copilot February 2, 2026 14:03
Copy link
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

Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

async def _serve(config: Config) -> None:
"""Async serve implementation."""
async with BalatroInstance(config) as instance:
print(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.")
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The print() statement should use typer.echo() for consistency with line 100 and the rest of the CLI commands. Using typer.echo() provides better integration with the Typer framework and allows for easier testing and output control.

Suggested change
print(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.")
typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.")

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 42
def _check_health(host: str, port: int, timeout: float = 2.0) -> bool:
"""Sync health check."""
try:
client = BalatroClient(host=host, port=port, timeout=timeout)
client.call("health")
return True
except Exception:
return False
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The _check_health function is defined but never used in the codebase. Consider removing it if it's not needed, or add a comment explaining its purpose if it's intended for future use or manual debugging.

Suggested change
def _check_health(host: str, port: int, timeout: float = 2.0) -> bool:
"""Sync health check."""
try:
client = BalatroClient(host=host, port=port, timeout=timeout)
client.call("health")
return True
except Exception:
return False

Copilot uses AI. Check for mistakes.
@$(PRINT) "$(YELLOW)Running tests/cli...$(RESET)"
pytest tests/cli
@$(PRINT) "$(YELLOW)Running tests/cli with $(XDIST_WORKERS) workers...$(RESET)"
pytest -n 2 tests/cli
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The log message says "Running tests/cli with $(XDIST_WORKERS) workers" but the actual command uses a hardcoded value of 2 workers. Consider either using the variable value in the command or updating the message to say "with 2 workers" for consistency.

Suggested change
pytest -n 2 tests/cli
pytest -n $(XDIST_WORKERS) tests/cli

Copilot uses AI. Check for mistakes.
Copy link
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

Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if worker_id == "master":
return ports[0]

worker_num = int(worker_id.replace("gw", ""))
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The cli_port fixture assumes worker_num will always be within the bounds of the allocated ports array. If the number of actual xdist workers differs from the expected parallelism, this could raise an IndexError. For example, if pytest is run with -n 3 but only 2 ports are allocated, worker gw2 would try to access ports[2] which doesn't exist. Consider adding bounds checking or ensuring the port allocation matches the actual worker count.

Suggested change
worker_num = int(worker_id.replace("gw", ""))
worker_num = int(worker_id.replace("gw", ""))
if worker_num >= len(ports):
raise pytest.UsageError(
f"Not enough CLI ports allocated for worker '{worker_id}'. "
f"Got worker index {worker_num}, but only {len(ports)} port(s) are configured: {ports!r}"
)

Copilot uses AI. Check for mistakes.
except json.JSONDecodeError as e:
typer.echo(f"Error: Invalid JSON params - {e}", err=True)
raise typer.Exit(code=1)

Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The JSON params validation only checks if the string is valid JSON, but doesn't verify that the parsed result is a dictionary. If a user provides valid JSON that isn't an object (e.g., an array like '[]' or a primitive like '123'), this will be passed to the client which expects a dict. The server may reject this, but it would be better to validate early and provide a clear error message. Consider adding a check after json.loads to ensure params_dict is a dictionary.

Suggested change
if not isinstance(params_dict, dict):
typer.echo(
"Error: Invalid JSON params - expected a JSON object (e.g. {\"key\": \"value\"})",
err=True,
)
raise typer.Exit(code=1)

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +8
from balatrobot.cli.client import APIError, BalatroClient
from balatrobot.config import Config
from balatrobot.manager import BalatroInstance

__version__ = "1.3.4"
__all__ = ["BalatroInstance", "Config", "__version__"]
__all__ = ["APIError", "BalatroClient", "BalatroInstance", "Config", "__version__"]
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The BalatroClient and APIError classes are now exported in the public API (all), but there's no documentation for them in the docs directory. Since these are now part of the public API surface, they should be documented to help users understand how to use them programmatically. Consider adding a section in docs/api.md or docs/example-bot.md showing how to use the BalatroClient for programmatic API access.

Copilot uses AI. Check for mistakes.
@S1M0N38 S1M0N38 merged commit 6e5fdd4 into main Feb 2, 2026
10 checks passed
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.

2 participants