hop3-cli 0.5.0.dev1__tar.gz → 0.5.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/PKG-INFO +22 -10
  2. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/README.md +21 -9
  3. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/pyproject.toml +1 -1
  4. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/arguments.py +24 -7
  5. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/destructive.py +1 -1
  6. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/flags.py +58 -25
  7. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/__init__.py +30 -27
  8. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/context_cmd.py +134 -55
  9. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/help_text.py +4 -4
  10. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/init_cmd.py +12 -2
  11. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/login_cmd.py +41 -70
  12. hop3_cli-0.5.0.dev3/src/hop3_cli/commands/local/project_context_cmd.py +776 -0
  13. hop3_cli-0.5.0.dev3/src/hop3_cli/commands/local/server_cmd.py +599 -0
  14. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/ssh_ops.py +20 -1
  15. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/config.py +73 -53
  16. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/core/aliases.py +0 -13
  17. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/core/app_scope.py +7 -1
  18. hop3_cli-0.5.0.dev3/src/hop3_cli/core/cli_state.py +117 -0
  19. hop3_cli-0.5.0.dev3/src/hop3_cli/core/context_names.py +72 -0
  20. hop3_cli-0.5.0.dev3/src/hop3_cli/core/deploy_preview.py +427 -0
  21. hop3_cli-0.5.0.dev3/src/hop3_cli/core/hop3_toml.py +86 -0
  22. hop3_cli-0.5.0.dev3/src/hop3_cli/core/local_overlay.py +335 -0
  23. hop3_cli-0.5.0.dev3/src/hop3_cli/core/project_guard.py +171 -0
  24. hop3_cli-0.5.0.dev3/src/hop3_cli/core/resolution.py +930 -0
  25. hop3_cli-0.5.0.dev3/src/hop3_cli/core/server_registry.py +309 -0
  26. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/exit_codes.py +24 -36
  27. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/main.py +281 -38
  28. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/rpc/client.py +11 -17
  29. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/rpc/responses.py +7 -2
  30. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/rpc/streaming.py +68 -31
  31. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/tokens.py +6 -3
  32. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/types.py +0 -1
  33. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/ui/prompts.py +0 -11
  34. hop3_cli-0.5.0.dev1/src/hop3_cli/core/resolution.py +0 -186
  35. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/__init__.py +0 -0
  36. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/__init__.py +0 -0
  37. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/help.py +0 -0
  38. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/aliases_cmd.py +0 -0
  39. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/auth_cmd.py +0 -0
  40. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/completion_cmd.py +0 -0
  41. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/settings_cmd.py +0 -0
  42. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/use_cmd.py +0 -0
  43. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/commands/local/version_cmd.py +0 -0
  44. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/core/__init__.py +0 -0
  45. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/core/alias_registry.py +0 -0
  46. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/core/suggest.py +0 -0
  47. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/exceptions.py +0 -0
  48. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/rpc/__init__.py +0 -0
  49. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/rpc/tunnel.py +0 -0
  50. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/ui/__init__.py +0 -0
  51. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/ui/console.py +0 -0
  52. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/ui/messages.py +0 -0
  53. {hop3_cli-0.5.0.dev1 → hop3_cli-0.5.0.dev3}/src/hop3_cli/ui/rich_printer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hop3-cli
3
- Version: 0.5.0.dev1
3
+ Version: 0.5.0.dev3
4
4
  Summary:
5
5
  Author: Stefane Fermigier
6
6
  Author-email: Stefane Fermigier <sf@abilian.com>
@@ -64,36 +64,48 @@ hop3 restart
64
64
 
65
65
  ## App Resolution
66
66
 
67
- Commands that act on a single app (`logs`, `restart`, `config set`, `run`, …) no longer take the app name as a positional argument. Instead the CLI resolves it from a layered chain, walking sources until one supplies a value (ADR 036 D7):
67
+ Commands that act on a single app (`logs`, `restart`, `config set`, `run`, …) no longer take the app name as a positional argument. Instead the CLI resolves it from a layered chain, walking sources until one supplies a value (ADR 042 §Resolution chains; supersedes ADR 036 §D7):
68
68
 
69
69
  1. `--app <name>` / `-a <name>` — explicit flag, wins over everything.
70
70
  2. `$HOP3_APP` — environment variable for the current shell.
71
71
  3. `.hop3-app` — one-line file in CWD or any ancestor up to `$HOME`. Drop it in a project repo and `hop3` from inside picks up the right app.
72
72
  4. `hop3.toml [cli].app` — same search path as `.hop3-app`, lower priority.
73
- 5. Active context's `default_app`set via `hop3 use <app>`.
73
+ 5. `hop3.toml [contexts.<resolved-context>].app` — when a project context resolves (see below), its `app` field becomes the source. Lets one project map per-environment to different apps.
74
+ 6. `hop3.toml [metadata].id` — the project's canonical name. The "I'm physically standing in this project" source; outranks any server-level default so being inside a project always wins over a sticky binding from elsewhere.
75
+ 7. Git remote `hop3-<env>` parsed for an app name — supports `git push hop3-prod main` style deploys.
76
+ 8. Active server's `default_app` from `~/.config/hop3-cli/servers.toml` — set via `hop3 server use --default-app <app>`. Lowest-priority fallback for single-app users.
74
77
 
75
- Use `hop3 --why <command>` to print the full trace and see which source won.
78
+ Use `hop3 --why <command>` to print the full trace and see which source won. `--why` is diagnostic-only: the command itself is not executed (so `hop3 deploy --why` is safe to run).
76
79
 
77
80
  ```bash
78
81
  # Explicit (always works):
79
82
  hop3 logs --app myapp
80
83
  hop3 config set --app myapp KEY=value
81
84
 
82
- # Sticky app for the context:
83
- hop3 use myapp
84
- hop3 logs
85
-
86
85
  # Per-shell:
87
86
  export HOP3_APP=myapp
88
87
 
89
- # Per-directory:
88
+ # Per-directory (legacy pin file):
90
89
  echo myapp > .hop3-app
91
90
 
91
+ # Per-project, declared in hop3.toml:
92
+ # [metadata]
93
+ # id = "myapp"
94
+ # [contexts.staging]
95
+ # app = "myapp-staging"
96
+
92
97
  # Debug:
93
98
  hop3 --why logs
94
99
  ```
95
100
 
96
- See [CLI Reference: App Resolution](../../docs/src/reference/cli.md#app-resolution) for the full specification.
101
+ The ADR 042 model splits the old "context" noun into two concepts:
102
+
103
+ - **Server records** — credentialed bindings to a Hop3 server. Live in `~/.config/hop3-cli/servers.toml`. Managed with `hop3 server` (`add` / `list` / `show` / `use` / `login` / `remove`).
104
+ - **Project contexts** — environment-shaped overlays on a single project. Declared as `[contexts.<name>]` blocks inside `hop3.toml`. Managed with `hop3 context` from inside a project tree (`init` / `use` / `list` / `show` / `add` / `remove`). The currently-selected one is recorded in `.hop3-local.toml` (gitignored automatically).
105
+
106
+ A `hop3 deploy` from a project tree resolves: server (which Hop3 to talk to) + context (which environment of this project) + app (which app on that server) — and shows a preview-and-confirm prompt with the plan before invoking the deploy RPC. `--dry-run` exits after printing the plan; `--force` bypasses the project-mismatch safety guard.
107
+
108
+ See [CLI Reference: App Resolution](../../docs/src/reference/cli.md#app-resolution) and [ADR 042](../../notes/adrs/042-cli-context-model.md) for the full specification.
97
109
 
98
110
  ## Configuration
99
111
 
@@ -44,36 +44,48 @@ hop3 restart
44
44
 
45
45
  ## App Resolution
46
46
 
47
- Commands that act on a single app (`logs`, `restart`, `config set`, `run`, …) no longer take the app name as a positional argument. Instead the CLI resolves it from a layered chain, walking sources until one supplies a value (ADR 036 D7):
47
+ Commands that act on a single app (`logs`, `restart`, `config set`, `run`, …) no longer take the app name as a positional argument. Instead the CLI resolves it from a layered chain, walking sources until one supplies a value (ADR 042 §Resolution chains; supersedes ADR 036 §D7):
48
48
 
49
49
  1. `--app <name>` / `-a <name>` — explicit flag, wins over everything.
50
50
  2. `$HOP3_APP` — environment variable for the current shell.
51
51
  3. `.hop3-app` — one-line file in CWD or any ancestor up to `$HOME`. Drop it in a project repo and `hop3` from inside picks up the right app.
52
52
  4. `hop3.toml [cli].app` — same search path as `.hop3-app`, lower priority.
53
- 5. Active context's `default_app`set via `hop3 use <app>`.
53
+ 5. `hop3.toml [contexts.<resolved-context>].app` — when a project context resolves (see below), its `app` field becomes the source. Lets one project map per-environment to different apps.
54
+ 6. `hop3.toml [metadata].id` — the project's canonical name. The "I'm physically standing in this project" source; outranks any server-level default so being inside a project always wins over a sticky binding from elsewhere.
55
+ 7. Git remote `hop3-<env>` parsed for an app name — supports `git push hop3-prod main` style deploys.
56
+ 8. Active server's `default_app` from `~/.config/hop3-cli/servers.toml` — set via `hop3 server use --default-app <app>`. Lowest-priority fallback for single-app users.
54
57
 
55
- Use `hop3 --why <command>` to print the full trace and see which source won.
58
+ Use `hop3 --why <command>` to print the full trace and see which source won. `--why` is diagnostic-only: the command itself is not executed (so `hop3 deploy --why` is safe to run).
56
59
 
57
60
  ```bash
58
61
  # Explicit (always works):
59
62
  hop3 logs --app myapp
60
63
  hop3 config set --app myapp KEY=value
61
64
 
62
- # Sticky app for the context:
63
- hop3 use myapp
64
- hop3 logs
65
-
66
65
  # Per-shell:
67
66
  export HOP3_APP=myapp
68
67
 
69
- # Per-directory:
68
+ # Per-directory (legacy pin file):
70
69
  echo myapp > .hop3-app
71
70
 
71
+ # Per-project, declared in hop3.toml:
72
+ # [metadata]
73
+ # id = "myapp"
74
+ # [contexts.staging]
75
+ # app = "myapp-staging"
76
+
72
77
  # Debug:
73
78
  hop3 --why logs
74
79
  ```
75
80
 
76
- See [CLI Reference: App Resolution](../../docs/src/reference/cli.md#app-resolution) for the full specification.
81
+ The ADR 042 model splits the old "context" noun into two concepts:
82
+
83
+ - **Server records** — credentialed bindings to a Hop3 server. Live in `~/.config/hop3-cli/servers.toml`. Managed with `hop3 server` (`add` / `list` / `show` / `use` / `login` / `remove`).
84
+ - **Project contexts** — environment-shaped overlays on a single project. Declared as `[contexts.<name>]` blocks inside `hop3.toml`. Managed with `hop3 context` from inside a project tree (`init` / `use` / `list` / `show` / `add` / `remove`). The currently-selected one is recorded in `.hop3-local.toml` (gitignored automatically).
85
+
86
+ A `hop3 deploy` from a project tree resolves: server (which Hop3 to talk to) + context (which environment of this project) + app (which app on that server) — and shows a preview-and-confirm prompt with the plan before invoking the deploy RPC. `--dry-run` exits after printing the plan; `--force` bypasses the project-mismatch safety guard.
87
+
88
+ See [CLI Reference: App Resolution](../../docs/src/reference/cli.md#app-resolution) and [ADR 042](../../notes/adrs/042-cli-context-model.md) for the full specification.
77
89
 
78
90
  ## Configuration
79
91
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hop3-cli"
3
- version = "0.5.0.dev1"
3
+ version = "0.5.0.dev3"
4
4
  authors = [
5
5
  {name = "Stefane Fermigier", email = "sf@abilian.com"},
6
6
  ]
@@ -133,26 +133,43 @@ def _resolve_password_inputs(args: list[str]) -> None:
133
133
  the next positional after the command tokens. Server-side commands keep
134
134
  their existing positional contract — the CLI is the security boundary.
135
135
 
136
- Applies only to the two commands that take a password:
136
+ Applies to the three commands that take a password:
137
137
  hop3 user add <username> <email> <password>
138
138
  hop3 user set-password <username> <password>
139
+ hop3 auth login <username> <password>
139
140
  """
140
- if len(args) < 2 or args[0] != "user":
141
- return
142
- if args[1] not in {"add", "set-password"}:
141
+ insert_at = _password_insert_index(args)
142
+ if insert_at is None:
143
143
  return
144
144
 
145
145
  password = _extract_password_flag(args)
146
146
  if password is None:
147
147
  return
148
148
 
149
- # Insertion index: after `user add <user> <email>` for add; after
150
- # `user set-password <user>` for set-password.
151
- insert_at = 4 if args[1] == "add" else 3
152
149
  insert_at = min(insert_at, len(args))
153
150
  args.insert(insert_at, password)
154
151
 
155
152
 
153
+ def _password_insert_index(args: list[str]) -> int | None:
154
+ """Return the positional index where the resolved password should land.
155
+
156
+ Returns None if the current ``args`` does not target a command that
157
+ accepts a password.
158
+ """
159
+ if len(args) < 2:
160
+ return None
161
+ if args[0] == "user" and args[1] == "add":
162
+ # user add <username> <email> <password>
163
+ return 4
164
+ if args[0] == "user" and args[1] == "set-password":
165
+ # user set-password <username> <password>
166
+ return 3
167
+ if args[0] == "auth" and args[1] == "login":
168
+ # auth login <username> <password>
169
+ return 3
170
+ return None
171
+
172
+
156
173
  def _extract_password_flag(args: list[str]) -> str | None:
157
174
  """Pop --password-file / --stdin from args and return the resolved password.
158
175
 
@@ -79,7 +79,7 @@ def _confirm_protected_context(config: Config | None) -> tuple[bool, str | None]
79
79
  return True, context_name
80
80
 
81
81
 
82
- def confirm_destructive_action(
82
+ def confirm_destructive_action( # noqa: PLR0911 — sequential decision tree, each return is a distinct escape hatch (json mode, no-match, missing-args, --confirm, --no-input, protected context, …) with its own side effects; flattening into a result var would obscure the safety story.
83
83
  cli_args: list[str],
84
84
  printer: RichPrinter,
85
85
  config: Config | None = None,
@@ -32,7 +32,15 @@ class CliFlags:
32
32
  """CLI flags that control output and behavior."""
33
33
 
34
34
  json_output: bool = False # --json, -j: Machine-readable JSON output
35
- skip_confirm: bool = False # -y, --yes, --force: Skip confirmation prompts
35
+ skip_confirm: bool = False # -y, --yes: Skip confirmation prompts
36
+
37
+ # ADR 042 §D14: --force is the dedicated bypass for the project-
38
+ # mismatch guard. It also implies skip_confirm (a --force user has
39
+ # already opted into "yes really do it"), but the converse is NOT
40
+ # true: -y/--yes alone must NOT silence the safety guard, or scripts
41
+ # running `hop3 deploy -y` from the wrong directory deploy to the
42
+ # wrong app — exactly the scenario the guard exists to prevent.
43
+ force: bool = False
36
44
 
37
45
  # Verbosity is now stored as a level (0=quiet, 1=normal, 2=verbose, 3=debug)
38
46
  # This allows -vv, -vvv, -qq, etc.
@@ -41,12 +49,18 @@ class CliFlags:
41
49
  # Context override for multi-server support
42
50
  context: str | None = None # --context <name>: Use a specific context
43
51
 
52
+ # ADR 042: `--server` / `-s` selects a server from the global registry
53
+ # explicitly. Highest-priority source in the server resolution chain;
54
+ # bypasses any context-derived server.
55
+ server: str | None = None
56
+
44
57
  # ADR 036 D5: `--app` / `-a` is always a flag, never positional.
45
58
  # ADR 036 D7: if not set, the CLI will resolve one via the app-resolution
46
59
  # chain (env, .hop3-app file, hop3.toml, context default).
47
60
  app: str | None = None
48
61
 
49
- # ADR 036: `--why` prints the resolution trace before running.
62
+ # ADR 036: `--why` prints the resolution trace and exits without running
63
+ # the command (diagnostic-only — avoids `hop3 deploy --why` deploying).
50
64
  why: bool = False
51
65
 
52
66
  # ADR 036: `--no-alias` bypasses alias resolution.
@@ -62,6 +76,11 @@ class CliFlags:
62
76
  # automation/CI; complements `--yes` (which says "yes, take action").
63
77
  no_input: bool = False
64
78
 
79
+ # ADR 042 §Deploy preview: `--dry-run` prints the deploy plan and
80
+ # exits without invoking the RPC. Analogous to `--why` but for the
81
+ # action plan rather than the resolution trace.
82
+ dry_run: bool = False
83
+
65
84
  @property
66
85
  def quiet(self) -> bool:
67
86
  """True if verbosity is 0 (quiet mode)."""
@@ -78,7 +97,7 @@ class CliFlags:
78
97
  return self.verbosity >= 3
79
98
 
80
99
 
81
- def _parse_verbosity_flag(arg: str, current: int) -> int | None:
100
+ def _parse_verbosity_flag(arg: str, current: int) -> int | None: # noqa: PLR0911 — mix of exact matches (--debug/--verbose/--quiet) and pattern matches (-v*/-d*/-q* with length-dependent verbosity); a unified table would have to encode the per-prefix length-to-level math and would read worse than the straight-line cascade.
82
101
  """Parse a verbosity-related flag; return the new verbosity or None if not one."""
83
102
  if arg == "--debug":
84
103
  return 3
@@ -135,12 +154,15 @@ def parse_flags(args: list[str]) -> tuple[CliFlags, list[str]]:
135
154
  state: dict[str, Any] = {
136
155
  "json_output": False,
137
156
  "skip_confirm": False,
157
+ "force": False,
138
158
  "context": None,
159
+ "server": None,
139
160
  "app": None,
140
161
  "why": False,
141
162
  "no_alias": False,
142
163
  "confirm_value": None,
143
164
  "no_input": False,
165
+ "dry_run": False,
144
166
  "verbosity": _get_env_verbosity() or 1,
145
167
  }
146
168
 
@@ -157,8 +179,30 @@ def parse_flags(args: list[str]) -> tuple[CliFlags, list[str]]:
157
179
  return CliFlags(**state), remaining_args
158
180
 
159
181
 
160
- _JSON_FLAGS = {"--json", "-j"}
161
- _YES_FLAGS = {"-y", "--yes", "--force"}
182
+ # Boolean flags: token → state field to set True. One row per logical flag,
183
+ # with aliases grouped in the tuple key.
184
+ _BOOL_FLAGS: dict[tuple[str, ...], str] = {
185
+ ("--json", "-j"): "json_output",
186
+ ("-y", "--yes"): "skip_confirm",
187
+ ("--why",): "why",
188
+ ("--no-alias",): "no_alias",
189
+ ("--no-input",): "no_input",
190
+ ("--dry-run",): "dry_run",
191
+ }
192
+
193
+
194
+ # --force needs its own row because it sets two flags at once: the
195
+ # §D14 bypass AND skip_confirm (a --force user has implicitly opted
196
+ # into the prompt skip). Handled in _apply_flag.
197
+ _FORCE_FLAG = "--force"
198
+
199
+ # Two-token "--flag value" pairs.
200
+ _PAIR_FLAGS: dict[tuple[str, ...], str] = {
201
+ ("--context", "-c"): "context",
202
+ ("--server", "-s"): "server",
203
+ ("--app", "-a"): "app",
204
+ ("--confirm",): "confirm_value",
205
+ }
162
206
 
163
207
 
164
208
  def _apply_flag(args: list[str], i: int, state: dict[str, Any]) -> int:
@@ -169,32 +213,21 @@ def _apply_flag(args: list[str], i: int, state: dict[str, Any]) -> int:
169
213
  """
170
214
  arg = args[i]
171
215
 
172
- if arg in _JSON_FLAGS:
173
- state["json_output"] = True
174
- return 1
175
- if arg in _YES_FLAGS:
216
+ if arg == _FORCE_FLAG:
217
+ state["force"] = True
176
218
  state["skip_confirm"] = True
177
219
  return 1
178
- if arg == "--why":
179
- state["why"] = True
180
- return 1
181
- if arg == "--no-alias":
182
- state["no_alias"] = True
183
- return 1
184
- if arg == "--no-input":
185
- state["no_input"] = True
186
- return 1
220
+
221
+ for keys, field_name in _BOOL_FLAGS.items():
222
+ if arg in keys:
223
+ state[field_name] = True
224
+ return 1
225
+
187
226
  if arg.startswith("--confirm="):
188
227
  state["confirm_value"] = arg.split("=", 1)[1]
189
228
  return 1
190
229
 
191
- # Two-token forms: ``--flag value``.
192
- pair_keys = {
193
- ("--context", "-c"): "context",
194
- ("--app", "-a"): "app",
195
- ("--confirm",): "confirm_value",
196
- }
197
- for keys, field_name in pair_keys.items():
230
+ for keys, field_name in _PAIR_FLAGS.items():
198
231
  if arg in keys and i + 1 < len(args):
199
232
  state[field_name] = args[i + 1]
200
233
  return 2
@@ -22,6 +22,7 @@ from .completion_cmd import handle_completion
22
22
  from .context_cmd import handle_context
23
23
  from .init_cmd import handle_init
24
24
  from .login_cmd import handle_login, handle_login_token
25
+ from .server_cmd import handle_server
25
26
  from .settings_cmd import handle_settings, settings_get, settings_set, settings_show
26
27
  from .ssh_ops import BootstrapError, extract_token, infer_server_url
27
28
  from .use_cmd import handle_use
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "handle_local_command",
45
46
  "handle_login",
46
47
  "handle_login_token",
48
+ "handle_server",
47
49
  "handle_settings",
48
50
  "handle_use",
49
51
  "handle_version",
@@ -59,9 +61,10 @@ __all__ = [
59
61
  LOCAL_COMMANDS_INFO = {
60
62
  "aliases": "List all effective aliases (built-in, plugin, user).",
61
63
  "completion": "Generate shell completion scripts.",
62
- "context": "Manage multiple server contexts.",
64
+ "context": "Manage project deploy contexts (or legacy server contexts).",
63
65
  "init": "Initialize connection to a Hop3 server via SSH.",
64
66
  "login": "Authenticate to a server.",
67
+ "server": "Manage server bindings (ADR 042).",
65
68
  "settings": "Manage local CLI settings (server URL, token, SSL).",
66
69
  "use": "Set / show / clear the current context's default app.",
67
70
  "version": "Show CLI version.",
@@ -97,6 +100,23 @@ def is_local_command(args: list[str]) -> bool:
97
100
  return not (command == "auth" and len(args) > 1 and not args[1].startswith("-"))
98
101
 
99
102
 
103
+ # Dispatch table for local commands. Each entry maps a CLI token to its
104
+ # handler. Handlers that always claim the command return None; ``auth`` is
105
+ # the one exception — it returns a bool to delegate back to the server when
106
+ # the subcommand belongs in the server-side `auth` namespace.
107
+ _LOCAL_HANDLERS = {
108
+ "completion": handle_completion,
109
+ "context": handle_context,
110
+ "init": handle_init,
111
+ "login": handle_login,
112
+ "server": handle_server,
113
+ "settings": handle_settings,
114
+ "aliases": handle_aliases,
115
+ "use": handle_use,
116
+ }
117
+ _VERSION_TOKENS = {"version", "--version", "-V"}
118
+
119
+
100
120
  def handle_local_command(args: list[str], config: Config, printer: RichPrinter) -> bool:
101
121
  """Handle a local command.
102
122
 
@@ -106,35 +126,18 @@ def handle_local_command(args: list[str], config: Config, printer: RichPrinter)
106
126
  if not args:
107
127
  return False
108
128
 
109
- command = args[0]
110
- cmd_args = args[1:]
129
+ command, cmd_args = args[0], args[1:]
111
130
 
112
- if command == "completion":
113
- handle_completion(cmd_args, config, printer)
114
- return True
115
- if command == "context":
116
- handle_context(cmd_args, config, printer)
117
- return True
118
- if command == "init":
119
- handle_init(cmd_args, config, printer)
120
- return True
121
- if command == "login":
122
- handle_login(cmd_args, config, printer)
123
- return True
124
- if command == "settings":
125
- handle_settings(cmd_args, config, printer)
126
- return True
127
- if command == "aliases":
128
- handle_aliases(cmd_args, config, printer)
129
- return True
130
- if command == "use":
131
- handle_use(cmd_args, config, printer)
132
- return True
133
- if command in {"version", "--version", "-V"}:
131
+ if command in _VERSION_TOKENS:
134
132
  handle_version(cmd_args, config, printer)
135
133
  return True
134
+
136
135
  if command == "auth":
137
- # handle_auth returns False if command should go to server
136
+ # handle_auth returns False if command should go to server.
138
137
  return handle_auth(cmd_args, config, printer)
139
138
 
140
- return False
139
+ handler = _LOCAL_HANDLERS.get(command)
140
+ if handler is None:
141
+ return False
142
+ handler(cmd_args, config, printer)
143
+ return True