hop3-cli Deep Dive¶
This document provides detailed internal documentation for the hop3-cli package. For a quick overview, see the package overview.
Architecture Overview¶
hop3-cli is a thin client that communicates with hop3-server via JSON-RPC. It handles:
- Argument Parsing - Command-line interface
- SSH Tunneling - Secure communication with remote servers
- RPC Communication - JSON-RPC over HTTP
- Output Formatting - Human-readable and JSON output
Module Structure¶
hop3_cli/
├── main.py # Entry point, argument parsing, command flow
├── config.py # Configuration and context resolution
├── exit_codes.py # Exit codes (ADR 036 D16)
├── exceptions.py # CLI exception classes
├── tokens.py # JWT extraction helpers
├── types.py # Type definitions
├── rpc/
│ ├── client.py # JSON-RPC client with SSH tunnel support
│ ├── responses.py # Response handling
│ └── streaming.py # Streaming (live deploy/log) responses
├── commands/
│ ├── flags.py # CLI flag parsing
│ ├── arguments.py # Argument parsing helpers
│ ├── destructive.py # Confirmation prompts
│ ├── help.py # Help system
│ └── local/ # Commands handled without the server (a package)
│ ├── init_cmd.py
│ ├── login_cmd.py
│ ├── settings_cmd.py
│ ├── context_cmd.py
│ ├── tunnel_cmd.py
│ └── ...
├── core/ # Resolution, aliases, credential store, project context
│ ├── credential_store.py # Per-server bearer-token store (credentials.toml)
│ ├── paths.py # Config-dir resolution (platformdirs / $HOP3_CONFIG_DIR)
│ └── ...
└── ui/
├── console.py # Output formatting
├── rich_printer.py # Rich terminal output
└── prompts.py # Interactive prompts
Communication Model¶
Direct HTTP Mode¶
Servers exposed directly (development or internal networks) take this form:
Configuration:
SSH Tunnel Mode¶
Production servers, reached over a secure channel, take this form:
The connection mode is selected by the scheme of the API URL: an ssh:// (or ssh+http://) URL routes RPC through an SSH tunnel, while an http(s):// URL connects directly.
The tunnel is created by the Client (in rpc/client.py) using an SshTunnel (core/ssh_tunnel.py), a subprocess ssh -L forward that shells out to the system ssh. On construction, the client parses the API URL and, when the scheme is ssh/ssh+http, opens the tunnel binding the remote server port (default 8000) to a local port. RPC then targets http://localhost:<local_port>/rpc. The client is a context manager, so the tunnel is closed reliably on exit:
JSON-RPC Protocol¶
The CLI uses JSON-RPC 2.0 over HTTP, posting to the server's /rpc endpoint. The CLI does not map each command to a distinct RPC method. Instead it forwards the raw command tokens under a single cli method, and the server parses, dispatches, and executes them. This keeps the client thin: command names, subcommands, and their behavior live entirely server-side.
// Request — `hop3 app list` forwarded as the `cli` method
{
"jsonrpc": "2.0",
"method": "cli",
"params": {
"cli_args": ["app", "list"],
"extra_args": {}
},
"id": 1
}
// Response
{
"jsonrpc": "2.0",
"result": [
{"name": "myapp", "state": "running", "port": 8001}
],
"id": 1
}
extra_args carries out-of-band values that don't belong in the argv (for example file contents read locally and passed to the server).
RPC Client¶
The Client (in rpc/client.py) takes the loaded Config, resolves the API URL, and opens an SSH tunnel when needed. Its rpc() method takes the method name ("cli") and the list of command tokens, attaches the bearer token, and parses the JSON-RPC response. If the server returns 401 and an SSH URL is configured, it transparently re-authenticates over SSH and retries once.
class Client:
def rpc(self, method: str, cli_args: list[str], **extra_args: Any) -> Response:
"""Call a remote method with automatic SSH-based authentication."""
response = self._do_rpc(method, cli_args, **extra_args)
if isinstance(response, Error) and response.code == 401:
if self._can_auto_auth():
self._auto_authenticate()
response = self._do_rpc(method, cli_args, **extra_args)
return response
Command Structure¶
Commands are organized by type:
Remote Commands¶
Most commands are forwarded to the server as the cli method, with the command tokens passed verbatim. The app target is always the --app NAME flag (ADR 036 D5); when omitted, the CLI resolves it from where you stand (env var $HOP3_APP, a .hop3-app file in the CWD or an ancestor, or hop3.toml) and injects it as --app before forwarding. There is no per-context default app (ADR 042).
# These are forwarded to the server as cli(["<tokens>"])
hop3 app list # `apps` is a built-in alias for `app list`
hop3 app create foo # create an app from a repository
hop3 deploy # package the current project and deploy
hop3 app logs --app myapp # show logs for an explicit app
hop3 app logs # same, app resolved from context
Local Commands¶
Some commands run entirely on the client, without an RPC call (see commands/local/): init, login, settings, context, use, tunnel, aliases, completion, version, and bare auth.
# These don't call the server
hop3 init # Bootstrap a server connection via SSH
hop3 settings show # Manage local CLI settings (URL, token, SSL)
hop3 version # Show CLI version
hop3 help # Show help
Configuration¶
Under ADR 042 the CLI's on-disk state is split in three: a secret-free config.toml for local preferences and contexts, a per-server credential store (credentials.toml) for bearer tokens, and a per-checkout binding (.hop3-local.toml, gitignored) naming the context a checkout uses. A context is a server you can reach, and lives only in config.toml; the committed hop3.toml never says where the app is deployed.
Config File (config.toml)¶
Location: ~/.config/hop3-cli/config.toml, resolved via platformdirs (so the exact path is platform-specific) and honoring $HOP3_CONFIG_DIR. It holds local preferences, the [contexts.<name>] blocks (server, optional bootstrap) and [cli].default_context (the global current context, used outside a project); it holds no bearer tokens.
[cli]
default_context = "devel" # the global current context, used outside a project
[contexts.devel]
server = "https://hop3.example.com" # the address every command connects to
bootstrap = "ssh://root@hop3.example.com" # used only to renew the token
The file is written atomically (tmpfile + os.replace) and chmod 0600 defensively, even though it carries no secrets.
Credential Store (credentials.toml)¶
Bearer tokens live in ~/.config/hop3-cli/credentials.toml (same config dir), keyed by the canonical server address:
This file is invisible plumbing: hop3 auth login / hop3 init populate it, deploy/RPC read it, and it is never hand-edited. It is created file-mode 0o600 with parent dir 0o700, and the store aborts loudly rather than ever leaving it group/world-readable (Hop3's "errors are never silent" rule). See core/credential_store.py.
Contexts and the binding¶
A context is a named server: server, plus an optional bootstrap ssh:// route used only to mint a token. It never holds an app, domains or environment; those per-deployment differences live in the project's hop3.toml, under [contexts.<name>] blocks keyed by context name and holding only app, domains and env (a server or bootstrap there is refused). Contexts are managed with hop3 context add|use|unset|list|show|remove|rename (see commands/local/context_cmd.py).
main.py resolves the target once per invocation, in two steps. core/resolution.py:resolve_context picks the context name by rule B of ADR 042: --context, then HOP3_CONTEXT (or the context env for HOP3_API_URL / HOP3_DEV_MODE), then, inside a project, the checkout's binding from .hop3-local.toml, and, outside a project, [cli].default_context. Inside a project with no binding the result is UNBOUND, never the global context. core/target.py:resolve_target then turns the name into a Target (context, server, bootstrap, source) or a TargetError that names the fix (hop3 context use <name>); a name that resolves to nothing aborts, whichever step supplied it. Target.describe() is the → <context> (<server>), <why> line each server-reaching command prints on stderr. There is no --server flag and no single-context fallback.
Config Class¶
Config wraps the parsed TOML data dict. main.py records the resolved target with set_target (or the refusal with set_target_error, raised the first time a server is needed); get_api_url returns the target's server, and the token comes from the credential store keyed by that same address, or from HOP3_API_TOKEN.
@dataclass
class Config:
data: dict = field(default_factory=dict)
config_file: Path | None = None
def set_target(self, target: Target) -> None: ...
def require_target(self) -> Target: ...
def get_api_url(self) -> str | None: ...
def get_api_token(self) -> str | None: ... # reads the credential store
def get_context_server(self, name: str) -> str | None: ... # context -> address
def get_default_context(self) -> str | None: ... # the global current context
Environment Variables¶
| Variable | Description |
|---|---|
HOP3_API_URL |
API URL; ssh:// scheme enables tunneling, http(s):// connects directly |
HOP3_API_TOKEN |
Authentication (bearer) token |
HOP3_CONTEXT |
Select the active context by name |
HOP3_CONFIG_DIR |
Override the config directory (config.toml + credentials.toml) |
HOP3_DEV_MODE |
When truthy, defaults the API URL to http://localhost:8000 |
Output Formatting¶
The CLI supports multiple output formats:
Human-Readable (default)¶
JSON¶
Quiet¶
Error Handling¶
The CLI handles errors at multiple levels:
- Connection errors - Network/SSH failures
- Authentication errors - Invalid or expired tokens
- RPC errors - Server-side command failures
- User errors - Invalid input
CLI exceptions derive from CliError (in exceptions.py):
class CliError(Exception):
"""Base class for CLI exceptions."""
class AuthenticationError(CliError):
"""Raised when authentication fails."""
class DeploymentError(CliError):
"""Raised when a deployment fails."""
Exit codes are defined centrally in exit_codes.py (ADR 036 D16) rather than as per-exception attributes, so scripts can distinguish failure classes:
class ExitCode:
SUCCESS = 0
GENERAL_ERROR = 1
USAGE_ERROR = 2
RESOLUTION_ERROR = 3 # app / context / target not found
AUTH_ERROR = 4
AUTHZ_ERROR = 5
CONFLICT_ERROR = 6
NETWORK_ERROR = 7
DEPLOYMENT_ERROR = 8
PLUGIN_ERROR = 9
CONFIRMATION_DECLINED = 10
INTERRUPTED = 130 # SIGINT
The JSON envelope includes error.exit_code so JSON consumers don't have to map error strings.
Authentication Flow¶
hop3 auth login (canonical spelling: hop3 auth login) is a local handler. Its
password path goes through the server's auth get-token primitive:
1. User runs: hop3 auth login (prompts for username/password)
2. CLI forwards the credentials as cli(["auth", "get-token", user, pass])
3. Server verifies them and returns a JWT token
4. CLI saves the token to the per-server credential store (credentials.toml)
and records the server as a context
5. Subsequent requests include: Authorization: Bearer <token>
hop3 auth get-token <user> --password-file - exposes that same primitive
directly, for scripts that want to capture a token without saving config.
hop3 auth login --ssh <target> obtains the token over SSH and stores it the same way: in the per-server credential store, keyed by the canonical server address. The server is recorded as a context in config.toml (named by --context, else default; still secret-free, since only the address is written), and becomes [cli].default_context only when no global current context is set; otherwise the login prints the hop3 context use <name> that would switch. config.toml is never where the token goes. When the API URL uses an ssh:// scheme, the CLI can also obtain a token over SSH automatically: on a 401 it runs the SSH bootstrap, fetches a token, stores it, and retries the request. The local hop3 init and hop3 auth login commands rely on that retry.
Development Notes¶
Adding a New Command¶
Most commands are implemented server-side; the CLI just forwards the tokens via the cli RPC method. To add one:
- Add a
Commandsubclass in hop3-server'scommands/(it defines its ownnameand help text) - Nothing is usually needed in the CLI: the new command is reachable as soon as the server exposes it
A purely client-side command (one with local side effects, like init or settings) is added under commands/local/ and registered in LOCAL_COMMANDS_INFO.
Testing¶
# Run the CLI test suite
pytest -x -p no:randomly src tests
# With coverage
pytest --cov hop3_cli tests src
Future Improvements¶
- Interactive mode (REPL)
- Richer progress reporting for long-running deploys
Multi-server support and shell completion already exist (contexts, bound per checkout with hop3 context use or chosen per command with --context, and the completion command, respectively).