hop3-cli 0.4.0b1__py3-none-any.whl
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.
- hop3_cli/__init__.py +1 -0
- hop3_cli/commands/__init__.py +47 -0
- hop3_cli/commands/arguments.py +190 -0
- hop3_cli/commands/destructive.py +100 -0
- hop3_cli/commands/flags.py +122 -0
- hop3_cli/commands/help.py +182 -0
- hop3_cli/commands/local/__init__.py +99 -0
- hop3_cli/commands/local/auth_cmd.py +52 -0
- hop3_cli/commands/local/help_text.py +92 -0
- hop3_cli/commands/local/init_cmd.py +164 -0
- hop3_cli/commands/local/login_cmd.py +460 -0
- hop3_cli/commands/local/settings_cmd.py +132 -0
- hop3_cli/commands/local/ssh_ops.py +204 -0
- hop3_cli/commands/local/version_cmd.py +25 -0
- hop3_cli/config.py +189 -0
- hop3_cli/exceptions.py +6 -0
- hop3_cli/exit_codes.py +102 -0
- hop3_cli/main.py +226 -0
- hop3_cli/rpc/__init__.py +29 -0
- hop3_cli/rpc/client.py +314 -0
- hop3_cli/rpc/responses.py +179 -0
- hop3_cli/rpc/tunnel.py +177 -0
- hop3_cli/tokens.py +26 -0
- hop3_cli/types.py +12 -0
- hop3_cli/ui/__init__.py +30 -0
- hop3_cli/ui/console.py +19 -0
- hop3_cli/ui/messages.py +40 -0
- hop3_cli/ui/prompts.py +90 -0
- hop3_cli/ui/rich_printer.py +247 -0
- hop3_cli-0.4.0b1.dist-info/METADATA +150 -0
- hop3_cli-0.4.0b1.dist-info/RECORD +33 -0
- hop3_cli-0.4.0b1.dist-info/WHEEL +4 -0
- hop3_cli-0.4.0b1.dist-info/entry_points.txt +4 -0
hop3_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Copyright (c) 2023-2025, Abilian SAS
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Copyright (c) 2025, Abilian SAS
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Command processing for the Hop3 CLI.
|
|
6
|
+
|
|
7
|
+
This package handles command parsing and local command execution:
|
|
8
|
+
- local: Commands handled locally without server RPC
|
|
9
|
+
- help: Help flag handling and help output injection
|
|
10
|
+
- destructive: Confirmation prompts for destructive commands
|
|
11
|
+
- flags: CLI flag parsing (--json, --quiet, -y, etc.)
|
|
12
|
+
- arguments: Argument generation (e.g., deploy archive)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .arguments import generate_archive, get_extra_args, pack_repository
|
|
18
|
+
from .destructive import confirm_destructive_action, is_destructive_command
|
|
19
|
+
from .flags import CliFlags, parse_flags
|
|
20
|
+
from .help import (
|
|
21
|
+
handle_help_flags,
|
|
22
|
+
inject_local_commands_into_help,
|
|
23
|
+
is_help_command,
|
|
24
|
+
)
|
|
25
|
+
from .local import (
|
|
26
|
+
LOCAL_COMMANDS,
|
|
27
|
+
LOCAL_COMMANDS_INFO,
|
|
28
|
+
handle_local_command,
|
|
29
|
+
is_local_command,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"LOCAL_COMMANDS",
|
|
34
|
+
"LOCAL_COMMANDS_INFO",
|
|
35
|
+
"CliFlags",
|
|
36
|
+
"confirm_destructive_action",
|
|
37
|
+
"generate_archive",
|
|
38
|
+
"get_extra_args",
|
|
39
|
+
"handle_help_flags",
|
|
40
|
+
"handle_local_command",
|
|
41
|
+
"inject_local_commands_into_help",
|
|
42
|
+
"is_destructive_command",
|
|
43
|
+
"is_help_command",
|
|
44
|
+
"is_local_command",
|
|
45
|
+
"pack_repository",
|
|
46
|
+
"parse_flags",
|
|
47
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Copyright (c) 2025, Abilian SAS
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Argument generation for CLI commands."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import io
|
|
11
|
+
import tarfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pathspec
|
|
15
|
+
|
|
16
|
+
from hop3_cli.types import JsonDict
|
|
17
|
+
|
|
18
|
+
__all__ = ["generate_archive", "get_extra_args", "pack_repository"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_extra_args(args: list[str], verbosity: int = 1) -> JsonDict:
|
|
22
|
+
"""Generate a dictionary of extra arguments for RPC commands.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
args: Command-line arguments
|
|
26
|
+
verbosity: Verbosity level (0=quiet, 1=normal, 2=verbose, 3=debug)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Dictionary with extra arguments. Verbosity is always included as it's
|
|
30
|
+
used by the server to set the logging context for all commands.
|
|
31
|
+
"""
|
|
32
|
+
# Always include verbosity - server extracts it and uses it as context
|
|
33
|
+
extra_args: JsonDict = {"verbosity": verbosity}
|
|
34
|
+
|
|
35
|
+
if not args:
|
|
36
|
+
return extra_args
|
|
37
|
+
|
|
38
|
+
command = args[0]
|
|
39
|
+
|
|
40
|
+
match command:
|
|
41
|
+
case "deploy":
|
|
42
|
+
# Parse deploy-specific flags
|
|
43
|
+
# args[0]="deploy", args[1]=app_name, remaining args may include --env and directory
|
|
44
|
+
env_vars, remaining_args = _parse_deploy_args(args[1:])
|
|
45
|
+
|
|
46
|
+
# Directory is the last non-flag argument (if any)
|
|
47
|
+
directory = Path(remaining_args[-1]) if len(remaining_args) > 1 else Path()
|
|
48
|
+
extra_args["repository"] = pack_repository(directory)
|
|
49
|
+
|
|
50
|
+
# Include env vars if any were specified
|
|
51
|
+
if env_vars:
|
|
52
|
+
extra_args["env_vars"] = env_vars
|
|
53
|
+
|
|
54
|
+
return extra_args
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_deploy_args(args: list[str]) -> tuple[dict[str, str], list[str]]:
|
|
58
|
+
"""Parse deploy command arguments, extracting --env flags.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
args: Arguments after 'deploy' command (app_name, --env flags, directory)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (env_vars dict, remaining args without --env flags)
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> _parse_deploy_args(['myapp', '--env', 'FOO=bar', '--env', 'BAZ=qux', '.'])
|
|
68
|
+
({'FOO': 'bar', 'BAZ': 'qux'}, ['myapp', '.'])
|
|
69
|
+
"""
|
|
70
|
+
env_vars: dict[str, str] = {}
|
|
71
|
+
remaining: list[str] = []
|
|
72
|
+
i = 0
|
|
73
|
+
|
|
74
|
+
while i < len(args):
|
|
75
|
+
arg = args[i]
|
|
76
|
+
|
|
77
|
+
if arg in {"--env", "-e"}:
|
|
78
|
+
# Next argument should be KEY=VALUE
|
|
79
|
+
if i + 1 < len(args):
|
|
80
|
+
env_spec = args[i + 1]
|
|
81
|
+
if "=" in env_spec:
|
|
82
|
+
key, _, value = env_spec.partition("=")
|
|
83
|
+
env_vars[key] = value
|
|
84
|
+
i += 2
|
|
85
|
+
else:
|
|
86
|
+
i += 1 # Skip malformed --env without value
|
|
87
|
+
elif arg.startswith("--env="):
|
|
88
|
+
# Handle --env=KEY=VALUE format
|
|
89
|
+
env_spec = arg[6:] # Remove --env=
|
|
90
|
+
if "=" in env_spec:
|
|
91
|
+
key, _, value = env_spec.partition("=")
|
|
92
|
+
env_vars[key] = value
|
|
93
|
+
i += 1
|
|
94
|
+
else:
|
|
95
|
+
remaining.append(arg)
|
|
96
|
+
i += 1
|
|
97
|
+
|
|
98
|
+
return env_vars, remaining
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def pack_repository(directory: Path = Path()) -> str:
|
|
102
|
+
"""Pack a directory into a base64-encoded tar.gz archive.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
directory: Directory to pack (defaults to current directory)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Base64-encoded tar.gz archive
|
|
109
|
+
"""
|
|
110
|
+
tar_gz = generate_archive(directory)
|
|
111
|
+
return base64.b64encode(tar_gz).decode("ascii")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def generate_archive(source_dir: Path) -> bytes:
|
|
115
|
+
"""
|
|
116
|
+
Creates an in-memory tar.gz archive of a source directory as a bytes object,
|
|
117
|
+
excluding files and directories specified in a .gitignore file.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
source_dir: The path to the directory to archive.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The content of the .tar.gz archive as a bytes object.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If the source_dir is not a valid directory.
|
|
127
|
+
FileNotFoundError: If the source_dir does not exist.
|
|
128
|
+
"""
|
|
129
|
+
source_dir = Path(source_dir).resolve()
|
|
130
|
+
|
|
131
|
+
if not source_dir.exists():
|
|
132
|
+
msg = f"Source directory not found: {source_dir}"
|
|
133
|
+
raise FileNotFoundError(msg)
|
|
134
|
+
if not source_dir.is_dir():
|
|
135
|
+
msg = f"Source path is not a directory: {source_dir}"
|
|
136
|
+
raise ValueError(msg)
|
|
137
|
+
|
|
138
|
+
# --- 1. Load .gitignore rules ---
|
|
139
|
+
spec = get_ignored_spec(source_dir)
|
|
140
|
+
|
|
141
|
+
# --- 2. Walk the directory and gather files to include ---
|
|
142
|
+
files_to_add = get_files_to_add(source_dir, spec)
|
|
143
|
+
|
|
144
|
+
# --- 3. Create the tar.gz archive in memory ---
|
|
145
|
+
fileobj = io.BytesIO()
|
|
146
|
+
|
|
147
|
+
# The 'w:gz' mode creates a gzip-compressed tar file.
|
|
148
|
+
# We pass our BytesIO object as the file to write to.
|
|
149
|
+
with tarfile.open(fileobj=fileobj, mode="w:gz") as tar:
|
|
150
|
+
for file_path in files_to_add:
|
|
151
|
+
relative_path = file_path.relative_to(source_dir)
|
|
152
|
+
arcname = Path() / relative_path
|
|
153
|
+
tar.add(file_path, arcname=str(arcname))
|
|
154
|
+
|
|
155
|
+
return fileobj.getvalue()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_ignored_spec(source_dir: Path) -> pathspec.PathSpec | None:
|
|
159
|
+
"""Load .gitignore rules from a directory."""
|
|
160
|
+
gitignore_path = source_dir / ".gitignore"
|
|
161
|
+
spec: pathspec.PathSpec | None = None
|
|
162
|
+
if gitignore_path.is_file():
|
|
163
|
+
with gitignore_path.open(encoding="utf-8") as f:
|
|
164
|
+
spec = pathspec.PathSpec.from_lines("gitignore", f)
|
|
165
|
+
return spec
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_files_to_add(source_dir: Path, spec: pathspec.PathSpec | None) -> list[Path]:
|
|
169
|
+
"""Get list of files to add to archive, excluding gitignored files."""
|
|
170
|
+
files_to_add: list[Path] = []
|
|
171
|
+
for file_path in source_dir.rglob("*"):
|
|
172
|
+
relative_path = file_path.relative_to(source_dir)
|
|
173
|
+
relative_str = str(relative_path)
|
|
174
|
+
|
|
175
|
+
# Always exclude .git directory (not deployment material)
|
|
176
|
+
if relative_str.startswith(".git") and (
|
|
177
|
+
relative_str == ".git" or relative_str.startswith(".git/")
|
|
178
|
+
):
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Let pathspec determine if the file should be ignored
|
|
182
|
+
if spec and spec.match_file(relative_str):
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# We only add files to the tar, not directories
|
|
186
|
+
if not file_path.is_file():
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
files_to_add.append(file_path)
|
|
190
|
+
return files_to_add
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright (c) 2025, Abilian SAS
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Destructive command handling and confirmation prompts."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from hop3_cli.ui.prompts import confirm, show_destructive_warning, type_to_confirm
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from hop3_cli.ui.rich_printer import RichPrinter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_destructive_command(cli_args: list[str]) -> bool:
|
|
18
|
+
"""Check if the command is destructive (requires confirmation).
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
cli_args: Command-line arguments
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
True if command is destructive, False otherwise
|
|
25
|
+
"""
|
|
26
|
+
if not cli_args:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
command = cli_args[0]
|
|
30
|
+
|
|
31
|
+
# List of destructive commands that require confirmation
|
|
32
|
+
destructive_commands = {
|
|
33
|
+
"app:destroy",
|
|
34
|
+
"destroy", # Alias for app:destroy
|
|
35
|
+
"backup:delete",
|
|
36
|
+
"services:destroy",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return command in destructive_commands
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def confirm_destructive_action(cli_args: list[str], printer: RichPrinter) -> bool:
|
|
43
|
+
"""Prompt user to confirm a destructive action.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
cli_args: Command-line arguments
|
|
47
|
+
printer: Printer for output (for JSON mode detection)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if user confirmed, False if cancelled
|
|
51
|
+
"""
|
|
52
|
+
if printer.json_output:
|
|
53
|
+
# In JSON mode, auto-confirm (user should use -y flag)
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
command = cli_args[0]
|
|
57
|
+
args = cli_args[1:]
|
|
58
|
+
|
|
59
|
+
# app:destroy or destroy command - requires type-to-confirm
|
|
60
|
+
if command in {"app:destroy", "destroy"}:
|
|
61
|
+
if not args:
|
|
62
|
+
# No app name provided, let server handle error
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
app_name = args[0]
|
|
66
|
+
show_destructive_warning(
|
|
67
|
+
"destroy",
|
|
68
|
+
f"app '{app_name}'",
|
|
69
|
+
"All files, data, and configuration will be permanently deleted.",
|
|
70
|
+
)
|
|
71
|
+
return type_to_confirm(f"Type '{app_name}' to confirm:", app_name)
|
|
72
|
+
|
|
73
|
+
# backup:delete command
|
|
74
|
+
if command == "backup:delete":
|
|
75
|
+
if not args:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
backup_id = args[0]
|
|
79
|
+
show_destructive_warning(
|
|
80
|
+
"delete",
|
|
81
|
+
f"backup '{backup_id}'",
|
|
82
|
+
"This backup cannot be recovered once deleted.",
|
|
83
|
+
)
|
|
84
|
+
return confirm("Are you sure you want to delete this backup?")
|
|
85
|
+
|
|
86
|
+
# services:destroy command
|
|
87
|
+
if command == "services:destroy":
|
|
88
|
+
if not args:
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
addon_name = args[0]
|
|
92
|
+
show_destructive_warning(
|
|
93
|
+
"destroy",
|
|
94
|
+
f"service '{addon_name}'",
|
|
95
|
+
"All data in this service will be permanently deleted.",
|
|
96
|
+
)
|
|
97
|
+
return type_to_confirm(f"Type '{addon_name}' to confirm:", addon_name)
|
|
98
|
+
|
|
99
|
+
# Unknown destructive command (shouldn't happen)
|
|
100
|
+
return confirm("This action cannot be undone. Continue?")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Copyright (c) 2023-2025, Abilian SAS
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""CLI flag parsing and handling."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_env_verbosity() -> int | None:
|
|
14
|
+
"""Get verbosity from HOP3_VERBOSITY environment variable.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Verbosity level (0-3) or None if not set or invalid
|
|
18
|
+
"""
|
|
19
|
+
env_val = os.environ.get("HOP3_VERBOSITY", "").strip()
|
|
20
|
+
if not env_val:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
level = int(env_val)
|
|
24
|
+
return max(0, min(3, level)) # Clamp to 0-3
|
|
25
|
+
except ValueError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CliFlags:
|
|
31
|
+
"""CLI flags that control output and behavior."""
|
|
32
|
+
|
|
33
|
+
json_output: bool = False # --json, -j: Machine-readable JSON output
|
|
34
|
+
skip_confirm: bool = False # -y, --yes, --force: Skip confirmation prompts
|
|
35
|
+
|
|
36
|
+
# Verbosity is now stored as a level (0=quiet, 1=normal, 2=verbose, 3=debug)
|
|
37
|
+
# This allows -vv, -vvv, -qq, etc.
|
|
38
|
+
verbosity: int = field(default_factory=lambda: _get_env_verbosity() or 1)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def quiet(self) -> bool:
|
|
42
|
+
"""True if verbosity is 0 (quiet mode)."""
|
|
43
|
+
return self.verbosity == 0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def verbose(self) -> bool:
|
|
47
|
+
"""True if verbosity is 2 or higher (verbose mode)."""
|
|
48
|
+
return self.verbosity >= 2
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def debug(self) -> bool:
|
|
52
|
+
"""True if verbosity is 3 (debug mode)."""
|
|
53
|
+
return self.verbosity >= 3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_flags(args: list[str]) -> tuple[CliFlags, list[str]]:
|
|
57
|
+
"""Parse CLI flags from arguments and return flags + remaining args.
|
|
58
|
+
|
|
59
|
+
Supports:
|
|
60
|
+
--json, -j: Machine-readable JSON output
|
|
61
|
+
-y, --yes, --force: Skip confirmation prompts
|
|
62
|
+
-v, --verbose: Increase verbosity (can stack: -vv, -vvv)
|
|
63
|
+
-q, --quiet: Decrease verbosity (can stack: -qq)
|
|
64
|
+
--debug: Maximum verbosity (level 3)
|
|
65
|
+
|
|
66
|
+
Environment variable:
|
|
67
|
+
HOP3_VERBOSITY: Set default verbosity level (0-3)
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
args: Command-line arguments (e.g., ['deploy', 'my-app', '--json', '-y'])
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (CliFlags, remaining_args)
|
|
74
|
+
remaining_args has flags removed
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
>>> parse_flags(['deploy', 'my-app', '--json'])
|
|
78
|
+
(CliFlags(json_output=True, ...), ['deploy', 'my-app'])
|
|
79
|
+
|
|
80
|
+
>>> parse_flags(['destroy', 'my-app', '-y', '-vv'])
|
|
81
|
+
(CliFlags(verbosity=3, skip_confirm=True, ...), ['destroy', 'my-app'])
|
|
82
|
+
"""
|
|
83
|
+
json_output = False
|
|
84
|
+
skip_confirm = False
|
|
85
|
+
|
|
86
|
+
# Start with environment default or normal (1)
|
|
87
|
+
verbosity = _get_env_verbosity() or 1
|
|
88
|
+
|
|
89
|
+
# Flags to recognize
|
|
90
|
+
json_flags = {"--json", "-j"}
|
|
91
|
+
yes_flags = {"-y", "--yes", "--force"}
|
|
92
|
+
|
|
93
|
+
# Filter out flags from args
|
|
94
|
+
remaining_args = []
|
|
95
|
+
for arg in args:
|
|
96
|
+
if arg in json_flags:
|
|
97
|
+
json_output = True
|
|
98
|
+
elif arg in yes_flags:
|
|
99
|
+
skip_confirm = True
|
|
100
|
+
elif arg == "--debug":
|
|
101
|
+
verbosity = 3
|
|
102
|
+
elif arg == "--verbose":
|
|
103
|
+
verbosity = max(verbosity, 2)
|
|
104
|
+
elif arg == "--quiet":
|
|
105
|
+
verbosity = 0
|
|
106
|
+
elif arg.startswith("-") and all(c == "v" for c in arg[1:]):
|
|
107
|
+
# Handle -v, -vv, -vvv
|
|
108
|
+
verbosity = min(3, 1 + len(arg) - 1) # -v=2, -vv=3, -vvv=3
|
|
109
|
+
elif arg.startswith("-") and all(c == "q" for c in arg[1:]):
|
|
110
|
+
# Handle -q, -qq (but -q is enough for quiet=0)
|
|
111
|
+
verbosity = 0
|
|
112
|
+
else:
|
|
113
|
+
# Not a flag, keep it
|
|
114
|
+
remaining_args.append(arg)
|
|
115
|
+
|
|
116
|
+
flags = CliFlags(
|
|
117
|
+
json_output=json_output,
|
|
118
|
+
skip_confirm=skip_confirm,
|
|
119
|
+
verbosity=verbosity,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return flags, remaining_args
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Copyright (c) 2025, Abilian SAS
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Help flag handling and help output injection."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .local import LOCAL_COMMANDS_INFO
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def handle_help_flags(args: list[str]) -> list[str]:
|
|
13
|
+
"""Convert --help/-h flags to help command invocations.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
["--help"] -> ["help"]
|
|
17
|
+
["-h"] -> ["help"]
|
|
18
|
+
["run", "--help"] -> ["help", "run"]
|
|
19
|
+
["run", "-h"] -> ["help", "run"]
|
|
20
|
+
["run", "myapp", "--help"] -> ["help", "run"] # help for run, not run with --help
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
args: Command-line arguments
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Modified arguments with --help converted to help command
|
|
27
|
+
"""
|
|
28
|
+
if not args:
|
|
29
|
+
return args
|
|
30
|
+
|
|
31
|
+
# Handle --version and -V flags
|
|
32
|
+
if "--version" in args or "-V" in args:
|
|
33
|
+
return ["version"]
|
|
34
|
+
|
|
35
|
+
# Check if --help or -h is anywhere in the args
|
|
36
|
+
if "--help" in args or "-h" in args:
|
|
37
|
+
# Remove --help and -h from args
|
|
38
|
+
filtered_args = [arg for arg in args if arg not in {"--help", "-h"}]
|
|
39
|
+
|
|
40
|
+
if not filtered_args:
|
|
41
|
+
# Just "--help" with no command -> show general help
|
|
42
|
+
return ["help"]
|
|
43
|
+
# "command --help" -> "help command"
|
|
44
|
+
# Only use the first argument as the command name
|
|
45
|
+
return ["help", filtered_args[0]]
|
|
46
|
+
|
|
47
|
+
return args
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_help_command(cli_args: list[str]) -> bool:
|
|
51
|
+
"""Check if this is a help command (with or without --all flag).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
cli_args: Command-line arguments
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if this is a help command that should have local commands injected
|
|
58
|
+
"""
|
|
59
|
+
if not cli_args:
|
|
60
|
+
return False
|
|
61
|
+
# Match "help" or "help --all" but not "help <command>"
|
|
62
|
+
if cli_args[0] != "help":
|
|
63
|
+
return False
|
|
64
|
+
# "help" alone or "help --all"
|
|
65
|
+
return len(cli_args) == 1 or cli_args == ["help", "--all"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def inject_local_commands_into_help(result: list[dict]) -> list[dict]:
|
|
69
|
+
"""Inject local CLI commands into the help output from the server.
|
|
70
|
+
|
|
71
|
+
Local commands (init, login, settings) are handled by the CLI and don't
|
|
72
|
+
exist on the server, so we add them to help output for discoverability.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
result: The help response from the server
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Modified result with local commands injected alphabetically
|
|
79
|
+
"""
|
|
80
|
+
modified_result = []
|
|
81
|
+
for item in result:
|
|
82
|
+
if item.get("t") != "text":
|
|
83
|
+
modified_result.append(item)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
text = item.get("text", "")
|
|
87
|
+
if "\n" in text and "COMMANDS" in text:
|
|
88
|
+
new_text = _process_help_text_with_local_commands(text, LOCAL_COMMANDS_INFO)
|
|
89
|
+
modified_result.append({"t": "text", "text": new_text})
|
|
90
|
+
else:
|
|
91
|
+
modified_result.append(item)
|
|
92
|
+
|
|
93
|
+
return modified_result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _process_help_text_with_local_commands(
|
|
97
|
+
text: str,
|
|
98
|
+
local_commands: dict[str, str],
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Process help text and inject local commands into COMMANDS section."""
|
|
101
|
+
lines = text.split("\n")
|
|
102
|
+
new_lines = []
|
|
103
|
+
in_commands_section = False
|
|
104
|
+
injected: set[str] = set()
|
|
105
|
+
|
|
106
|
+
for line in lines:
|
|
107
|
+
if line.strip() in {"COMMANDS", "ALL COMMANDS"}:
|
|
108
|
+
in_commands_section = True
|
|
109
|
+
new_lines.append(line)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if in_commands_section and line.strip() and not line.startswith(" "):
|
|
113
|
+
# Leaving COMMANDS section - inject remaining commands first
|
|
114
|
+
new_lines.extend(_inject_remaining_commands(local_commands, injected))
|
|
115
|
+
in_commands_section = False
|
|
116
|
+
|
|
117
|
+
if in_commands_section and _is_command_line(line):
|
|
118
|
+
current_cmd = _get_command_name(line)
|
|
119
|
+
if current_cmd:
|
|
120
|
+
new_lines.extend(
|
|
121
|
+
_inject_commands_before(current_cmd, local_commands, injected)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
new_lines.append(line)
|
|
125
|
+
|
|
126
|
+
# If still in commands section at end, inject remaining
|
|
127
|
+
if in_commands_section:
|
|
128
|
+
remaining = _inject_remaining_commands(local_commands, injected)
|
|
129
|
+
if remaining:
|
|
130
|
+
# Insert after last command line
|
|
131
|
+
insert_idx = len(new_lines)
|
|
132
|
+
for i in range(len(new_lines) - 1, -1, -1):
|
|
133
|
+
if _is_command_line(new_lines[i]):
|
|
134
|
+
insert_idx = i + 1
|
|
135
|
+
break
|
|
136
|
+
for j, cmd_line in enumerate(remaining):
|
|
137
|
+
new_lines.insert(insert_idx + j, cmd_line)
|
|
138
|
+
|
|
139
|
+
return "\n".join(new_lines)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _inject_remaining_commands(
|
|
143
|
+
local_commands: dict[str, str],
|
|
144
|
+
injected: set[str],
|
|
145
|
+
) -> list[str]:
|
|
146
|
+
"""Return all local commands not yet injected."""
|
|
147
|
+
lines = []
|
|
148
|
+
for cmd in sorted(local_commands.keys()):
|
|
149
|
+
if cmd not in injected:
|
|
150
|
+
lines.append(_format_help_command(cmd, local_commands[cmd]))
|
|
151
|
+
injected.add(cmd)
|
|
152
|
+
return lines
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_command_line(line: str) -> bool:
|
|
156
|
+
"""Check if a line is a command entry (indented, non-empty)."""
|
|
157
|
+
return line.startswith(" ") and bool(line.strip())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _get_command_name(line: str) -> str | None:
|
|
161
|
+
"""Extract command name from a help line."""
|
|
162
|
+
parts = line.strip().split(None, 1)
|
|
163
|
+
return parts[0] if parts else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _inject_commands_before(
|
|
167
|
+
current_cmd: str,
|
|
168
|
+
local_commands: dict[str, str],
|
|
169
|
+
injected: set[str],
|
|
170
|
+
) -> list[str]:
|
|
171
|
+
"""Return local commands that should appear before current_cmd alphabetically."""
|
|
172
|
+
lines = []
|
|
173
|
+
for cmd in sorted(local_commands.keys()):
|
|
174
|
+
if cmd not in injected and cmd < current_cmd:
|
|
175
|
+
lines.append(_format_help_command(cmd, local_commands[cmd]))
|
|
176
|
+
injected.add(cmd)
|
|
177
|
+
return lines
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _format_help_command(name: str, description: str) -> str:
|
|
181
|
+
"""Format a command entry for help output."""
|
|
182
|
+
return f" {name:16} {description}"
|