llmport-cli 0.1.0__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.
Files changed (42) hide show
  1. llmport/__init__.py +16 -0
  2. llmport/cli.py +95 -0
  3. llmport/commands/__init__.py +1 -0
  4. llmport/commands/admin.py +154 -0
  5. llmport/commands/config.py +146 -0
  6. llmport/commands/deploy.py +370 -0
  7. llmport/commands/dev/__init__.py +1 -0
  8. llmport/commands/dev/dev_doctor.py +46 -0
  9. llmport/commands/dev/dev_group.py +22 -0
  10. llmport/commands/dev/dev_init.py +555 -0
  11. llmport/commands/dev/dev_status.py +146 -0
  12. llmport/commands/dev/dev_up.py +247 -0
  13. llmport/commands/doctor.py +135 -0
  14. llmport/commands/down.py +65 -0
  15. llmport/commands/logs_cmd.py +41 -0
  16. llmport/commands/module.py +86 -0
  17. llmport/commands/status.py +71 -0
  18. llmport/commands/tune.py +99 -0
  19. llmport/commands/up.py +61 -0
  20. llmport/commands/version.py +34 -0
  21. llmport/core/__init__.py +1 -0
  22. llmport/core/api_client.py +77 -0
  23. llmport/core/bootstrap.py +211 -0
  24. llmport/core/compose.py +349 -0
  25. llmport/core/console.py +106 -0
  26. llmport/core/detect.py +375 -0
  27. llmport/core/env_gen.py +295 -0
  28. llmport/core/git.py +164 -0
  29. llmport/core/install.py +272 -0
  30. llmport/core/registry.py +302 -0
  31. llmport/core/settings.py +138 -0
  32. llmport/core/sysinfo.py +183 -0
  33. llmport/templates/env.j2 +33 -0
  34. llmport/tui/__init__.py +1 -0
  35. llmport/tui/widgets/__init__.py +1 -0
  36. llmport/tui/wizard/__init__.py +1 -0
  37. llmport_cli-0.1.0.dist-info/METADATA +263 -0
  38. llmport_cli-0.1.0.dist-info/RECORD +42 -0
  39. llmport_cli-0.1.0.dist-info/WHEEL +4 -0
  40. llmport_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. llmport_cli-0.1.0.dist-info/licenses/LICENSE +187 -0
  42. llmport_cli-0.1.0.dist-info/licenses/NOTICE +15 -0
llmport/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """llmport — CLI installer and management tool for llm.port.
2
+
3
+ Layered command structure built on Click framework.
4
+ Install, configure, and manage the full platform.
5
+ YAML-driven configuration persisted in llmport.yaml.
6
+ Async HTTP calls to backend REST API via httpx.
7
+ Native Docker Compose integration for orchestration.
8
+ Automatic system detection for OS, GPU, and Docker.
9
+ GPU vendor discovery supports NVIDIA and AMD ROCm.
10
+ Rich terminal output with tables, panels, progress.
11
+ Advanced TUI wizard powered by Textual framework.
12
+ Module activation via Docker Compose profiles.
13
+ All secrets generated securely during init phase.
14
+ """
15
+
16
+ __version__ = "0.1.0"
llmport/cli.py ADDED
@@ -0,0 +1,95 @@
1
+ """CLI entry point — Click root group and subcommand registration.
2
+
3
+ This module defines the ``llmport`` command group and wires up all
4
+ subcommands. Run ``llmport --help`` for the full command tree.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import click
10
+
11
+ from llmport import __version__
12
+
13
+
14
+ class AliasedGroup(click.Group):
15
+ """Click group that supports command abbreviations.
16
+
17
+ For instance ``llmport st`` resolves to ``llmport status`` if no
18
+ other command starts with ``st``.
19
+ """
20
+
21
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
22
+ rv = click.Group.get_command(self, ctx, cmd_name)
23
+ if rv is not None:
24
+ return rv
25
+ matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
26
+ if not matches:
27
+ return None
28
+ if len(matches) == 1:
29
+ return click.Group.get_command(self, ctx, matches[0])
30
+ ctx.fail(f"Ambiguous command '{cmd_name}'. Did you mean: {', '.join(sorted(matches))}?")
31
+ return None # unreachable, but satisfies type checker
32
+
33
+
34
+ @click.group(cls=AliasedGroup, invoke_without_command=True)
35
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.")
36
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress informational output.")
37
+ @click.version_option(version=__version__, prog_name="llmport")
38
+ @click.pass_context
39
+ def cli(ctx: click.Context, *, verbose: bool, quiet: bool) -> None:
40
+ """llmport — CLI installer and management tool for llm.port."""
41
+ ctx.ensure_object(dict)
42
+ ctx.obj["verbose"] = verbose
43
+ ctx.obj["quiet"] = quiet
44
+
45
+ if ctx.invoked_subcommand is None:
46
+ click.echo(ctx.get_help())
47
+
48
+
49
+ # ── Register subcommands ──────────────────────────────────────────
50
+
51
+ def register_core_commands(group: click.Group | None = None) -> None:
52
+ """Import and register all core subcommand groups and commands.
53
+
54
+ Accepts an optional *group* parameter so the EE CLI can call this
55
+ with its own root group. Defaults to ``cli`` when called internally.
56
+ """
57
+ target = group or cli
58
+
59
+ from llmport.commands.version import version_cmd
60
+ from llmport.commands.doctor import doctor_cmd
61
+ from llmport.commands.status import status_cmd
62
+ from llmport.commands.up import up_cmd
63
+ from llmport.commands.down import down_cmd
64
+ from llmport.commands.logs_cmd import logs_cmd
65
+ from llmport.commands.config import config_group
66
+ from llmport.commands.module import module_group
67
+ from llmport.commands.tune import tune_cmd
68
+ from llmport.commands.deploy import deploy_cmd
69
+ from llmport.commands.dev.dev_group import dev_group
70
+ from llmport.commands.admin import admin_group
71
+
72
+ target.add_command(version_cmd, "version")
73
+ target.add_command(doctor_cmd, "doctor")
74
+ target.add_command(status_cmd, "status")
75
+ target.add_command(up_cmd, "up")
76
+ target.add_command(down_cmd, "down")
77
+ target.add_command(logs_cmd, "logs")
78
+ target.add_command(config_group, "config")
79
+ target.add_command(module_group, "module")
80
+ target.add_command(tune_cmd, "tune")
81
+ target.add_command(deploy_cmd, "deploy")
82
+ target.add_command(dev_group, "dev")
83
+ target.add_command(admin_group, "admin")
84
+
85
+
86
+ register_core_commands()
87
+
88
+
89
+ def main() -> None:
90
+ """Package entry point."""
91
+ cli()
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1 @@
1
+ """Command implementations for the llmport CLI."""
@@ -0,0 +1,154 @@
1
+ """``llmport admin`` — administrative commands for a running deployment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import secrets
7
+ import shutil
8
+ import string
9
+ import subprocess
10
+ import sys
11
+
12
+ import click
13
+
14
+ from llmport.core.console import console, error, info, success, warning
15
+
16
+
17
+ def _generate_password(length: int = 24) -> str:
18
+ """Generate a random password (letters + digits + punctuation subset)."""
19
+ alphabet = string.ascii_letters + string.digits + "!@#$%&*-_=+"
20
+ return "".join(secrets.choice(alphabet) for _ in range(length))
21
+
22
+
23
+ # Minimal email validation — rejects obvious injection attempts.
24
+ _EMAIL_RE = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-.]+$")
25
+
26
+
27
+ @click.group("admin")
28
+ def admin_group() -> None:
29
+ """Administrative commands for a running llm.port deployment."""
30
+
31
+
32
+ @admin_group.command("reset-password")
33
+ @click.option(
34
+ "--email",
35
+ default="admin@localhost",
36
+ show_default=True,
37
+ help="Email address of the user whose password will be reset.",
38
+ )
39
+ @click.option(
40
+ "--password",
41
+ default=None,
42
+ help="New password. Auto-generated if omitted.",
43
+ )
44
+ @click.option(
45
+ "-y", "--yes",
46
+ is_flag=True,
47
+ help="Skip confirmation prompt.",
48
+ )
49
+ def reset_password_cmd(*, email: str, password: str | None, yes: bool) -> None:
50
+ """Reset a user's password directly via the database.
51
+
52
+ This bypasses the API and sets the password hash in PostgreSQL.
53
+ The backend container must be running (needed for password hashing).
54
+ """
55
+ # Validate email early to prevent any injection
56
+ if not _EMAIL_RE.match(email):
57
+ error(f"Invalid email address: {email}")
58
+ sys.exit(1)
59
+
60
+ docker = shutil.which("docker")
61
+ if not docker:
62
+ error("docker not found on PATH")
63
+ sys.exit(1)
64
+
65
+ new_password = password or _generate_password()
66
+
67
+ if not yes:
68
+ click.confirm(
69
+ f"Reset password for '{email}'?",
70
+ abort=True,
71
+ )
72
+
73
+ # ── 1. Hash the password inside the backend container ─────
74
+ info("Hashing new password…")
75
+ hash_script = (
76
+ "from fastapi_users.password import PasswordHelper; "
77
+ "print(PasswordHelper().hash(input()))"
78
+ )
79
+
80
+ hash_result = subprocess.run( # noqa: S603
81
+ [
82
+ docker, "exec", "-i", "llm-port-backend",
83
+ "python", "-c", hash_script,
84
+ ],
85
+ input=new_password,
86
+ capture_output=True,
87
+ text=True,
88
+ check=False,
89
+ )
90
+ if hash_result.returncode != 0:
91
+ error("Failed to hash password — is the backend container running?")
92
+ if hash_result.stderr.strip():
93
+ error(f" {hash_result.stderr.strip()}")
94
+ sys.exit(1)
95
+
96
+ hashed = hash_result.stdout.strip()
97
+ if not hashed:
98
+ error("Password hashing returned empty result.")
99
+ sys.exit(1)
100
+
101
+ # ── 2. Update the password in PostgreSQL ──────────────────
102
+ info("Updating password in database…")
103
+
104
+ # Both `hashed` (bcrypt output we generated) and `email` (validated
105
+ # above by regex) are safe for SQL string interpolation here.
106
+ sql = (
107
+ f"UPDATE \"user\" SET hashed_password = '{hashed}' "
108
+ f"WHERE email = '{email}';"
109
+ )
110
+
111
+ pg_result = subprocess.run( # noqa: S603
112
+ [
113
+ docker, "exec", "llm-port-postgres",
114
+ "psql", "-U", "postgres", "-d", "llm_port_backend",
115
+ "-c", sql,
116
+ ],
117
+ capture_output=True,
118
+ text=True,
119
+ check=False,
120
+ )
121
+
122
+ if pg_result.returncode != 0:
123
+ error("Database update failed — is the postgres container running?")
124
+ if pg_result.stderr.strip():
125
+ error(f" {pg_result.stderr.strip()}")
126
+ sys.exit(1)
127
+
128
+ # Check if a row was actually updated
129
+ if "UPDATE 0" in pg_result.stdout:
130
+ warning(f"No user found with email '{email}'.")
131
+ sys.exit(1)
132
+
133
+ success(f"Password reset for '{email}'.")
134
+
135
+ # ── 3. Display credentials ────────────────────────────────
136
+ from rich.panel import Panel
137
+ from rich.text import Text
138
+
139
+ lines = Text()
140
+ lines.append(" Email: ", style="bold")
141
+ lines.append(email, style="cyan")
142
+ lines.append("\n Password: ", style="bold")
143
+ lines.append(new_password, style="cyan")
144
+ lines.append("\n\n Store this in a password manager.", style="dim")
145
+
146
+ console.print()
147
+ console.print(
148
+ Panel(
149
+ lines,
150
+ title="[bold yellow]New Credentials[/bold yellow]",
151
+ border_style="yellow",
152
+ padding=(1, 2),
153
+ )
154
+ )
@@ -0,0 +1,146 @@
1
+ """``llmport config`` — view and manage configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import yaml
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+
12
+ from llmport.core.console import console, success, error
13
+ from llmport.core.settings import load_config, save_config, config_path
14
+
15
+
16
+ @click.group("config")
17
+ def config_group() -> None:
18
+ """Manage llmport configuration."""
19
+
20
+
21
+ @config_group.command("show")
22
+ def config_show() -> None:
23
+ """Display the current configuration."""
24
+ cfg_path = config_path()
25
+
26
+ if not cfg_path.exists():
27
+ console.print(f"[dim]No config file found at {cfg_path}[/dim]")
28
+ console.print("[dim]Using default configuration.[/dim]")
29
+ cfg = load_config()
30
+ import dataclasses
31
+
32
+ raw = yaml.dump(dataclasses.asdict(cfg), default_flow_style=False, sort_keys=False)
33
+ console.print(Panel(Syntax(raw, "yaml", theme="monokai"), title="defaults"))
34
+ return
35
+
36
+ raw = cfg_path.read_text(encoding="utf-8")
37
+ console.print(
38
+ Panel(
39
+ Syntax(raw, "yaml", theme="monokai"),
40
+ title=str(cfg_path),
41
+ border_style="cyan",
42
+ )
43
+ )
44
+
45
+
46
+ @config_group.command("set")
47
+ @click.argument("key")
48
+ @click.argument("value")
49
+ def config_set(key: str, value: str) -> None:
50
+ """Set a configuration value (dot-notation supported).
51
+
52
+ Examples:
53
+
54
+ llmport config set install_dir /opt/llmport
55
+
56
+ llmport config set dev.branch main
57
+ """
58
+ cfg = load_config()
59
+ import dataclasses
60
+
61
+ cfg_dict = dataclasses.asdict(cfg)
62
+
63
+ # Support dot-notation for nested keys (e.g., dev.branch)
64
+ parts = key.split(".")
65
+ target = cfg_dict
66
+ for part in parts[:-1]:
67
+ if part not in target or not isinstance(target[part], dict):
68
+ error(f"Unknown config section: {part}")
69
+ return
70
+ target = target[part]
71
+
72
+ final_key = parts[-1]
73
+ if final_key not in target:
74
+ error(f"Unknown config key: {key}")
75
+ return
76
+
77
+ # Coerce value types
78
+ old_val = target[final_key]
79
+ if isinstance(old_val, bool):
80
+ value_typed: str | bool | int | float | list[str] = value.lower() in ("true", "1", "yes")
81
+ elif isinstance(old_val, int):
82
+ value_typed = int(value)
83
+ elif isinstance(old_val, float):
84
+ value_typed = float(value)
85
+ elif isinstance(old_val, list):
86
+ value_typed = [v.strip() for v in value.split(",")]
87
+ else:
88
+ value_typed = value
89
+
90
+ target[final_key] = value_typed
91
+
92
+ # Rebuild config
93
+ from llmport.core.settings import LlmportConfig, DevConfig
94
+
95
+ dev_data = cfg_dict.pop("dev", {}) or {}
96
+ dev_cfg = DevConfig(**{k: v for k, v in dev_data.items() if k in DevConfig.__dataclass_fields__})
97
+ new_cfg = LlmportConfig(**cfg_dict, dev=dev_cfg)
98
+
99
+ save_config(new_cfg)
100
+ success(f"Set {key} = {value_typed}")
101
+
102
+
103
+ @config_group.command("path")
104
+ def config_path_cmd() -> None:
105
+ """Print the config file path."""
106
+ click.echo(str(config_path()))
107
+
108
+
109
+ @config_group.command("init")
110
+ @click.option("--force", is_flag=True, help="Overwrite existing config file.")
111
+ def config_init(*, force: bool) -> None:
112
+ """Create a default configuration file."""
113
+ cfg_file = config_path()
114
+
115
+ if cfg_file.exists() and not force:
116
+ error(f"Config file already exists at {cfg_file}. Use --force to overwrite.")
117
+ return
118
+
119
+ cfg = load_config()
120
+ save_config(cfg)
121
+ success(f"Configuration written to {cfg_file}")
122
+
123
+
124
+ @config_group.command("edit")
125
+ def config_edit() -> None:
126
+ """Open the config file in $EDITOR."""
127
+ import os
128
+ import subprocess
129
+
130
+ cfg_file = config_path()
131
+ if not cfg_file.exists():
132
+ # Create default first
133
+ cfg = load_config()
134
+ save_config(cfg)
135
+
136
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
137
+ if not editor:
138
+ # Fallback based on platform
139
+ import platform
140
+
141
+ if platform.system() == "Windows":
142
+ editor = "notepad"
143
+ else:
144
+ editor = "nano"
145
+
146
+ subprocess.run([editor, str(cfg_file)], check=False)