nodepick-cli 0.1.1__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.
- nodepick_cli/__init__.py +11 -0
- nodepick_cli/auth/__init__.py +3 -0
- nodepick_cli/auth/login.py +112 -0
- nodepick_cli/commands/__init__.py +8 -0
- nodepick_cli/commands/ai.py +168 -0
- nodepick_cli/commands/node.py +235 -0
- nodepick_cli/commands/ssh.py +102 -0
- nodepick_cli/core/__init__.py +16 -0
- nodepick_cli/core/config.py +74 -0
- nodepick_cli/core/exceptions.py +73 -0
- nodepick_cli/core/formatters.py +39 -0
- nodepick_cli/main.py +62 -0
- nodepick_cli-0.1.1.dist-info/METADATA +134 -0
- nodepick_cli-0.1.1.dist-info/RECORD +16 -0
- nodepick_cli-0.1.1.dist-info/WHEEL +4 -0
- nodepick_cli-0.1.1.dist-info/entry_points.txt +2 -0
nodepick_cli/__init__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import typer
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
import keyring
|
|
6
|
+
import nodepick
|
|
7
|
+
from ..core.config import get_api_key, get_base_url, load_config, save_config
|
|
8
|
+
from ..core.exceptions import handle_error
|
|
9
|
+
|
|
10
|
+
KEYRING_SERVICE = "nodepick-cli"
|
|
11
|
+
KEYRING_API_KEY = "api_key"
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(name="auth", help="Manage API key authentication.")
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_api_key_with_source() -> tuple:
|
|
18
|
+
"""Return (api_key, source) where source is a human-readable label."""
|
|
19
|
+
if os.getenv("NODEPICK_API_KEY"):
|
|
20
|
+
return os.getenv("NODEPICK_API_KEY"), "env var NODEPICK_API_KEY"
|
|
21
|
+
try:
|
|
22
|
+
kr_key = keyring.get_password(KEYRING_SERVICE, KEYRING_API_KEY)
|
|
23
|
+
if kr_key:
|
|
24
|
+
return kr_key, "OS keyring"
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return None, None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _keyring_get(key_name: str) -> Optional[str]:
|
|
31
|
+
try:
|
|
32
|
+
return keyring.get_password(KEYRING_SERVICE, key_name) or None
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _keyring_set(key_name: str, value: str) -> None:
|
|
38
|
+
keyring.set_password(KEYRING_SERVICE, key_name, value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _keyring_delete(key_name: str) -> None:
|
|
42
|
+
try:
|
|
43
|
+
keyring.delete_password(KEYRING_SERVICE, key_name)
|
|
44
|
+
except Exception:
|
|
45
|
+
pass # already absent
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("clear")
|
|
49
|
+
def auth_clear():
|
|
50
|
+
"""Remove the API key from the OS keyring."""
|
|
51
|
+
try:
|
|
52
|
+
_keyring_delete(KEYRING_API_KEY)
|
|
53
|
+
console.print("[bold green]API key cleared from OS keyring.[/bold green]")
|
|
54
|
+
except Exception as e:
|
|
55
|
+
handle_error(e, "Failed to clear credentials")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("configure")
|
|
59
|
+
@app.command("save")
|
|
60
|
+
def auth_configure(
|
|
61
|
+
api_key: Optional[str] = typer.Option(
|
|
62
|
+
None, "--api-key", "-k", help="Nodepick API key / bearer token"
|
|
63
|
+
),
|
|
64
|
+
base_url: Optional[str] = typer.Option(
|
|
65
|
+
None, "--base-url", "-u", help="API base URL (default: https://api.nodepick.ai)"
|
|
66
|
+
),
|
|
67
|
+
):
|
|
68
|
+
"""Configure API key (and optional base URL) securely in the OS keyring."""
|
|
69
|
+
try:
|
|
70
|
+
if not api_key:
|
|
71
|
+
api_key = typer.prompt("Nodepick API Key", hide_input=True)
|
|
72
|
+
|
|
73
|
+
resolved_url = (base_url or get_base_url()).rstrip("/")
|
|
74
|
+
|
|
75
|
+
_keyring_set(KEYRING_API_KEY, api_key)
|
|
76
|
+
# Save base_url to config file; only the API key lives in the keyring.
|
|
77
|
+
save_config({"base_url": resolved_url})
|
|
78
|
+
|
|
79
|
+
console.print(
|
|
80
|
+
"[bold green]Credentials configured.[/bold green] "
|
|
81
|
+
f"API key stored in OS keyring. Base URL: {resolved_url} (saved to config)"
|
|
82
|
+
)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
handle_error(e, "Failed to configure credentials")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("test")
|
|
88
|
+
def auth_test():
|
|
89
|
+
"""Test API access using the stored API key."""
|
|
90
|
+
key, source = _get_api_key_with_source()
|
|
91
|
+
url = get_base_url()
|
|
92
|
+
if not key:
|
|
93
|
+
console.print(
|
|
94
|
+
"[yellow]No API key found.[/yellow] "
|
|
95
|
+
"Run 'np auth configure' or set NODEPICK_API_KEY."
|
|
96
|
+
)
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
client = nodepick.NodePickClient(api_key=key, base_url=url)
|
|
101
|
+
user_info = client.get_me()
|
|
102
|
+
user = user_info.get("user", {})
|
|
103
|
+
org = user_info.get("org", {})
|
|
104
|
+
console.print(
|
|
105
|
+
f"[bold green]API access OK.[/bold green] "
|
|
106
|
+
f"User: [bold]{user.get('email', 'unknown')}[/bold] | "
|
|
107
|
+
f"Org: {org.get('name', 'N/A')} | "
|
|
108
|
+
f"URL: {url} | "
|
|
109
|
+
f"Key source: [dim]{source}[/dim]"
|
|
110
|
+
)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
handle_error(e, "API access test failed")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from ..commands.node import get_client
|
|
7
|
+
from ..core.exceptions import handle_error
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(name="ai", help="[BETA] Manage AI integrations and agent configurations.")
|
|
10
|
+
mcp_app = typer.Typer(name="mcp", help="Manage Model Context Protocol (MCP) integrations.")
|
|
11
|
+
app.add_typer(mcp_app, name="mcp")
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
SUPPORTED_AGENTS = ["antigravity"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _update_json_mcp_servers(file_path: Path, servers: Dict[str, Any]) -> None:
|
|
19
|
+
"""Safely merge MCP servers into a JSON configuration file."""
|
|
20
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
data = {}
|
|
22
|
+
if file_path.exists():
|
|
23
|
+
try:
|
|
24
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
25
|
+
content = f.read().strip()
|
|
26
|
+
if content:
|
|
27
|
+
data = json.loads(content)
|
|
28
|
+
except Exception:
|
|
29
|
+
data = {}
|
|
30
|
+
|
|
31
|
+
if not isinstance(data, dict):
|
|
32
|
+
data = {}
|
|
33
|
+
|
|
34
|
+
if "mcpServers" not in data or not isinstance(data["mcpServers"], dict):
|
|
35
|
+
data["mcpServers"] = {}
|
|
36
|
+
|
|
37
|
+
data["mcpServers"].update(servers)
|
|
38
|
+
|
|
39
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(data, f, indent=2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure_antigravity_mcp(servers: Dict[str, Any]) -> List[Path]:
|
|
44
|
+
"""Configure MCP servers for Google Antigravity across its standard configuration locations."""
|
|
45
|
+
home = Path.home()
|
|
46
|
+
target_files = [
|
|
47
|
+
home / ".gemini" / "config" / "mcp_config.json",
|
|
48
|
+
home / ".gemini" / "settings.json",
|
|
49
|
+
home / ".gemini" / "antigravity-cli" / "settings.json",
|
|
50
|
+
]
|
|
51
|
+
updated_files = []
|
|
52
|
+
for path in target_files:
|
|
53
|
+
try:
|
|
54
|
+
_update_json_mcp_servers(path, servers)
|
|
55
|
+
updated_files.append(path)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
console.print(f"[dim yellow]Warning: Failed writing to {path}: {e}[/dim yellow]")
|
|
58
|
+
return updated_files
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp_app.command("configure")
|
|
62
|
+
def mcp_configure(
|
|
63
|
+
agent: str = typer.Argument(
|
|
64
|
+
...,
|
|
65
|
+
help=f"AI Agent to configure (supported: {', '.join(SUPPORTED_AGENTS)})",
|
|
66
|
+
),
|
|
67
|
+
nodes: Optional[List[str]] = typer.Argument(
|
|
68
|
+
None, help="Node IDs or display names to configure MCP for (if omitted, configures all nodes)"
|
|
69
|
+
),
|
|
70
|
+
):
|
|
71
|
+
"""Configure MCP servers for an AI agent (e.g. antigravity) to connect to compute nodes."""
|
|
72
|
+
agent_clean = agent.strip().lower()
|
|
73
|
+
if agent_clean not in ("antigravity", "agy", "gemini"):
|
|
74
|
+
console.print(
|
|
75
|
+
f"[bold red]Unsupported agent '{agent}'.[/bold red] Supported agents: [bold]"
|
|
76
|
+
+ ", ".join(SUPPORTED_AGENTS)
|
|
77
|
+
+ "[/bold]"
|
|
78
|
+
)
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
|
|
81
|
+
client = get_client()
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
if nodes:
|
|
85
|
+
target_nodes = list(nodes)
|
|
86
|
+
else:
|
|
87
|
+
all_nodes = client.node_list()
|
|
88
|
+
if not all_nodes:
|
|
89
|
+
console.print("[yellow]No compute nodes found to configure.[/yellow]")
|
|
90
|
+
return
|
|
91
|
+
target_nodes = [
|
|
92
|
+
node.get("vm_uuid") or node.get("id") or node.get("display_name")
|
|
93
|
+
for node in all_nodes
|
|
94
|
+
]
|
|
95
|
+
target_nodes = [n for n in target_nodes if n]
|
|
96
|
+
|
|
97
|
+
servers_to_add: Dict[str, Any] = {}
|
|
98
|
+
|
|
99
|
+
for node_identifier in target_nodes:
|
|
100
|
+
try:
|
|
101
|
+
details = client.node_get_details(node_identifier)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
handle_error(e, f"Failed to get details for node '{node_identifier}'")
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
connect = details.get("connect") or {}
|
|
107
|
+
if not connect and "vm" in details and isinstance(details["vm"], dict):
|
|
108
|
+
connect = details["vm"].get("connect") or {}
|
|
109
|
+
|
|
110
|
+
mcp_url = connect.get("mcpUrl") or details.get("mcpUrl") or details.get("nmcpUrl")
|
|
111
|
+
mcp_api_key = connect.get("mcpApiKey") or details.get("mcpApiKey")
|
|
112
|
+
|
|
113
|
+
node_name = (
|
|
114
|
+
details.get("display_name")
|
|
115
|
+
or details.get("displayName")
|
|
116
|
+
or details.get("name")
|
|
117
|
+
or details.get("vm_uuid")
|
|
118
|
+
or details.get("id")
|
|
119
|
+
or node_identifier
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not mcp_url:
|
|
123
|
+
console.print(
|
|
124
|
+
f"[yellow]Node '{node_name}' does not have an active MCP endpoint ('mcpUrl'). Skipping.[/yellow]"
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
server_config: Dict[str, Any] = {
|
|
129
|
+
"serverUrl": mcp_url,
|
|
130
|
+
"url": mcp_url,
|
|
131
|
+
"type": "http",
|
|
132
|
+
"transport": "http",
|
|
133
|
+
"protocol": "streamable_http",
|
|
134
|
+
"insecure": True,
|
|
135
|
+
"insecureSkipVerify": True,
|
|
136
|
+
"rejectUnauthorized": False,
|
|
137
|
+
"verify": False,
|
|
138
|
+
"tls": {
|
|
139
|
+
"insecure": True,
|
|
140
|
+
"insecureSkipVerify": True,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
if mcp_api_key:
|
|
144
|
+
server_config["headers"] = {
|
|
145
|
+
"Authorization": f"Bearer {mcp_api_key}",
|
|
146
|
+
"X-API-Key": mcp_api_key,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
servers_to_add[node_name] = server_config
|
|
150
|
+
|
|
151
|
+
if not servers_to_add:
|
|
152
|
+
console.print("[yellow]No active MCP servers found for the specified node(s).[/yellow]")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
if agent_clean in ("antigravity", "agy", "gemini"):
|
|
156
|
+
configure_antigravity_mcp(servers_to_add)
|
|
157
|
+
|
|
158
|
+
for s_name, s_cfg in servers_to_add.items():
|
|
159
|
+
console.print(
|
|
160
|
+
f"[bold green]✔[/bold green] Configured MCP server '[bold]{s_name}[/bold]' -> [cyan]{s_cfg['url']}[/cyan]"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
console.print(
|
|
164
|
+
f"[bold green]Successfully configured {len(servers_to_add)} MCP server(s) for {agent_clean}.[/bold green]"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
handle_error(e, "Error configuring MCP servers")
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table, Column
|
|
5
|
+
import nodepick
|
|
6
|
+
from ..core.config import get_api_key, get_base_url
|
|
7
|
+
from ..core.exceptions import handle_error
|
|
8
|
+
from ..core.formatters import OutputFormat, set_output_format, get_output_format, print_output
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(name="node", help="Manage Compute Nodes.")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
from click.core import ParameterSource
|
|
16
|
+
|
|
17
|
+
@app.callback(invoke_without_command=True)
|
|
18
|
+
def node_callback():
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_client() -> nodepick.NodePickClient:
|
|
23
|
+
key = get_api_key()
|
|
24
|
+
url = get_base_url()
|
|
25
|
+
if not key:
|
|
26
|
+
console.print(
|
|
27
|
+
"[yellow]No API key found.[/yellow] "
|
|
28
|
+
"Run [bold]np auth configure[/bold] to store your API key."
|
|
29
|
+
)
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
return nodepick.NodePickClient(api_key=key, base_url=url)
|
|
32
|
+
|
|
33
|
+
def _extract_ssh_command(details) -> str:
|
|
34
|
+
ssh = ((details or {}).get("connect") or {}).get("ssh") or {}
|
|
35
|
+
cmd = ssh.get("command")
|
|
36
|
+
if cmd:
|
|
37
|
+
return cmd
|
|
38
|
+
host = ssh.get("sshHost")
|
|
39
|
+
port = ssh.get("sshPort")
|
|
40
|
+
username = ssh.get("username", "nodepick")
|
|
41
|
+
if host and port:
|
|
42
|
+
return f"ssh {username}@{host} -p {port}"
|
|
43
|
+
return "N/A"
|
|
44
|
+
|
|
45
|
+
def _get_ssh_command(client, node) -> str:
|
|
46
|
+
node_id = node.get("vm_uuid") or node.get("vmUuid") or node.get("vmId") or node.get("id")
|
|
47
|
+
try:
|
|
48
|
+
details = client.node_get_details(node_id)
|
|
49
|
+
return _extract_ssh_command(details)
|
|
50
|
+
except Exception:
|
|
51
|
+
return "N/A"
|
|
52
|
+
|
|
53
|
+
def _render_nodes_table(nodes, client=None):
|
|
54
|
+
if not nodes:
|
|
55
|
+
console.print("[yellow]No nodes found.[/yellow]")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
table = Table("ID", "Name", "State", Column("SSH Connect", no_wrap=True))
|
|
59
|
+
for node in nodes:
|
|
60
|
+
node_id = node.get("vm_uuid") or node.get("vmUuid") or node.get("vmId") or node.get("id", "N/A")
|
|
61
|
+
name = node.get("display_name") or node.get("displayName") or "N/A"
|
|
62
|
+
vmm = node.get("vmm", {}) if isinstance(node.get("vmm"), dict) else {}
|
|
63
|
+
state = node.get("state") or vmm.get("state") or node.get("status", "unknown")
|
|
64
|
+
ssh_cmd = _get_ssh_command(client, node) if client else "N/A"
|
|
65
|
+
table.add_row(
|
|
66
|
+
str(node_id),
|
|
67
|
+
str(name),
|
|
68
|
+
str(state),
|
|
69
|
+
ssh_cmd,
|
|
70
|
+
)
|
|
71
|
+
console.print(table)
|
|
72
|
+
|
|
73
|
+
@app.command("boot")
|
|
74
|
+
def node_boot(
|
|
75
|
+
node_id: str = typer.Argument(..., help="Node ID or display name to boot"),
|
|
76
|
+
):
|
|
77
|
+
"""Boot a node."""
|
|
78
|
+
client = get_client()
|
|
79
|
+
try:
|
|
80
|
+
console.print(f"[cyan]Booting node {node_id}...[/cyan]")
|
|
81
|
+
res = client.node_boot(node_id)
|
|
82
|
+
console.print(f"[bold green]Node {node_id} boot request sent.[/bold green]")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
handle_error(e, "Error booting node")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("create")
|
|
88
|
+
def node_create(
|
|
89
|
+
display_name: Optional[str] = typer.Option(None, "--name", "-n", help="Display name for the node"),
|
|
90
|
+
cpu: int = typer.Option(1, "--cpu", "-c", help="Number of vCPUs"),
|
|
91
|
+
memory_mb: int = typer.Option(512, "--memory", "-m", help="Memory size in MB"),
|
|
92
|
+
network_type: str = typer.Option("private", "--network", help="Network type (private/public)"),
|
|
93
|
+
storage_gb: Optional[int] = typer.Option(None, "--storage", help="Disk storage in GB (default: 10)"),
|
|
94
|
+
):
|
|
95
|
+
"""Deploy a new compute node."""
|
|
96
|
+
client = get_client()
|
|
97
|
+
memory_bytes = memory_mb * 1024 * 1024
|
|
98
|
+
try:
|
|
99
|
+
console.print("[cyan]Creating node...[/cyan]")
|
|
100
|
+
node = client.node_create(
|
|
101
|
+
memory=memory_bytes,
|
|
102
|
+
cpu=cpu,
|
|
103
|
+
network_type=network_type,
|
|
104
|
+
display_name=display_name,
|
|
105
|
+
storage_gb=storage_gb,
|
|
106
|
+
)
|
|
107
|
+
node_id = node.get("vm_uuid")
|
|
108
|
+
console.print(f"[bold green]Node created successfully![/bold green] ID: {node_id}")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
handle_error(e, "Error creating node")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command("delete")
|
|
114
|
+
def node_delete(
|
|
115
|
+
node_id: str = typer.Argument(..., help="Node ID or display name to delete"),
|
|
116
|
+
):
|
|
117
|
+
"""Delete a compute node."""
|
|
118
|
+
client = get_client()
|
|
119
|
+
try:
|
|
120
|
+
console.print(f"[cyan]Deleting node {node_id}...[/cyan]")
|
|
121
|
+
res = client.node_delete(node_id)
|
|
122
|
+
console.print(f"[bold green]Node {node_id} deleted.[/bold green]")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
handle_error(e, "Error deleting node")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _render_node_details_table(node):
|
|
128
|
+
if not node:
|
|
129
|
+
console.print("[yellow]No node details found.[/yellow]")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
table = Table("Field", "Value")
|
|
133
|
+
fields = [
|
|
134
|
+
("ID", node.get("vm_uuid") or node.get("id")),
|
|
135
|
+
("Org ID", node.get("orgId") or node.get("org_id")),
|
|
136
|
+
("User ID", node.get("created_by_id")),
|
|
137
|
+
("Server ID", node.get("serverId") or node.get("server_id")),
|
|
138
|
+
("Name", node.get("display_name")),
|
|
139
|
+
("State", node.get("state")),
|
|
140
|
+
("Status", node.get("status")),
|
|
141
|
+
("SSH Connect", _extract_ssh_command(node)),
|
|
142
|
+
("CPU Cores", node.get("cpu")),
|
|
143
|
+
(
|
|
144
|
+
"Memory (MB)",
|
|
145
|
+
round(node.get("memory_bytes") / (1024 * 1024))
|
|
146
|
+
if node.get("memory_bytes") is not None
|
|
147
|
+
else node.get("memory_mb"),
|
|
148
|
+
),
|
|
149
|
+
(
|
|
150
|
+
"Storage (GB)",
|
|
151
|
+
round(node.get("storage_bytes") / (1024 * 1024 * 1024))
|
|
152
|
+
if node.get("storage_bytes") is not None
|
|
153
|
+
else node.get("storage_gb"),
|
|
154
|
+
),
|
|
155
|
+
("Region", node.get("region")),
|
|
156
|
+
]
|
|
157
|
+
for field, value in fields:
|
|
158
|
+
if value is not None:
|
|
159
|
+
table.add_row(field, str(value))
|
|
160
|
+
console.print(table)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command("get")
|
|
164
|
+
def node_get(
|
|
165
|
+
node_id: str = typer.Argument(..., help="Node ID or display name"),
|
|
166
|
+
format: OutputFormat = typer.Option(
|
|
167
|
+
OutputFormat.TABLE,
|
|
168
|
+
"--format", "-f",
|
|
169
|
+
help="Output format (table or json).",
|
|
170
|
+
case_sensitive=False,
|
|
171
|
+
),
|
|
172
|
+
):
|
|
173
|
+
"""Get details of a specific node."""
|
|
174
|
+
set_output_format(format)
|
|
175
|
+
client = get_client()
|
|
176
|
+
try:
|
|
177
|
+
details = client.node_get_details(node_id)
|
|
178
|
+
print_output(details, table_render_func=_render_node_details_table)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
handle_error(e, "Error getting node details")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("list")
|
|
184
|
+
def node_list(
|
|
185
|
+
format: OutputFormat = typer.Option(
|
|
186
|
+
OutputFormat.TABLE,
|
|
187
|
+
"--format", "-f",
|
|
188
|
+
help="Output format (table or json).",
|
|
189
|
+
case_sensitive=False,
|
|
190
|
+
),
|
|
191
|
+
):
|
|
192
|
+
"""List all compute nodes."""
|
|
193
|
+
set_output_format(format)
|
|
194
|
+
client = get_client()
|
|
195
|
+
try:
|
|
196
|
+
nodes = client.node_list()
|
|
197
|
+
print_output(nodes, table_render_func=lambda data: _render_nodes_table(data, client))
|
|
198
|
+
except Exception as e:
|
|
199
|
+
handle_error(e, "Error listing nodes")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command("reboot")
|
|
203
|
+
def node_reboot(
|
|
204
|
+
node_id: str = typer.Argument(..., help="Node ID or display name to reboot"),
|
|
205
|
+
):
|
|
206
|
+
"""Reboot a node."""
|
|
207
|
+
client = get_client()
|
|
208
|
+
try:
|
|
209
|
+
console.print(f"[cyan]Rebooting node {node_id}...[/cyan]")
|
|
210
|
+
res = client.node_reboot(node_id)
|
|
211
|
+
console.print(f"[bold green]Node {node_id} reboot request sent.[/bold green]")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
handle_error(e, "Error rebooting node")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command("shutdown")
|
|
217
|
+
def node_shutdown(
|
|
218
|
+
node_id: str = typer.Argument(..., help="Node ID or display name to shut down"),
|
|
219
|
+
):
|
|
220
|
+
"""Gracefully shut down a node."""
|
|
221
|
+
client = get_client()
|
|
222
|
+
try:
|
|
223
|
+
console.print(f"[cyan]Shutting down node {node_id}...[/cyan]")
|
|
224
|
+
res = client.node_shutdown(node_id)
|
|
225
|
+
console.print(f"[bold green]Node {node_id} shutdown request sent.[/bold green]")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
handle_error(e, "Error shutting down node")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
import nodepick
|
|
6
|
+
from ..core.config import get_api_key, get_base_url
|
|
7
|
+
from ..core.exceptions import handle_error
|
|
8
|
+
from ..core.formatters import OutputFormat, set_output_format, print_output
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(name="ssh", help="Manage developer SSH public keys.")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from click.core import ParameterSource
|
|
16
|
+
|
|
17
|
+
@app.callback(invoke_without_command=True)
|
|
18
|
+
def ssh_callback():
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_client() -> nodepick.NodePickClient:
|
|
23
|
+
key = get_api_key()
|
|
24
|
+
url = get_base_url()
|
|
25
|
+
if not key:
|
|
26
|
+
console.print(
|
|
27
|
+
"[yellow]No API key found.[/yellow] "
|
|
28
|
+
"Run [bold]np auth configure[/bold] to store your API key."
|
|
29
|
+
)
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
return nodepick.NodePickClient(api_key=key, base_url=url)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render_ssh_keys_table(keys):
|
|
35
|
+
if not keys:
|
|
36
|
+
console.print("[yellow]No SSH keys found.[/yellow]")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
table = Table("ID", "Name", "Fingerprint", "Created At")
|
|
40
|
+
for key in keys:
|
|
41
|
+
table.add_row(
|
|
42
|
+
str(key.get("id", "N/A")),
|
|
43
|
+
str(key.get("name", "N/A")),
|
|
44
|
+
str(key.get("fingerprint", "N/A")),
|
|
45
|
+
str(key.get("created_at", "N/A")),
|
|
46
|
+
)
|
|
47
|
+
console.print(table)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("add")
|
|
51
|
+
def add_ssh_key(
|
|
52
|
+
name: str = typer.Option(..., "--name", "-n", help="Friendly name for the SSH key"),
|
|
53
|
+
key_file: Optional[str] = typer.Option(None, "--file", "-f", help="Path to SSH public key file (e.g. ~/.ssh/id_ed25519.pub)"),
|
|
54
|
+
public_key: Optional[str] = typer.Option(None, "--key", help="Raw SSH public key string"),
|
|
55
|
+
):
|
|
56
|
+
"""Add an SSH public key for automatic injection into new compute nodes."""
|
|
57
|
+
client = get_client()
|
|
58
|
+
try:
|
|
59
|
+
ssh_str = public_key
|
|
60
|
+
if not ssh_str and key_file:
|
|
61
|
+
with open(key_file, "r", encoding="utf-8") as f:
|
|
62
|
+
ssh_str = f.read().strip()
|
|
63
|
+
if not ssh_str:
|
|
64
|
+
raise ValueError("Must provide either --file or --key")
|
|
65
|
+
|
|
66
|
+
res = client.key_create(name=name, public_key=ssh_str)
|
|
67
|
+
key_id = res.get("key", {}).get("id", "N/A")
|
|
68
|
+
console.print(f"[bold green]SSH Key registered successfully![/bold green] Key ID: {key_id}")
|
|
69
|
+
except Exception as e:
|
|
70
|
+
handle_error(e, "Error adding SSH key")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("delete")
|
|
74
|
+
def delete_ssh_key(
|
|
75
|
+
key_id: str = typer.Argument(..., help="Key ID to delete"),
|
|
76
|
+
):
|
|
77
|
+
"""Delete an SSH key."""
|
|
78
|
+
client = get_client()
|
|
79
|
+
try:
|
|
80
|
+
res = client.key_delete(key_id)
|
|
81
|
+
console.print(f"[bold green]SSH key {key_id} deleted.[/bold green]")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
handle_error(e, "Error deleting SSH key")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("list")
|
|
87
|
+
def list_ssh_keys(
|
|
88
|
+
format: OutputFormat = typer.Option(
|
|
89
|
+
OutputFormat.TABLE,
|
|
90
|
+
"--format", "-f",
|
|
91
|
+
help="Output format (table or json).",
|
|
92
|
+
case_sensitive=False,
|
|
93
|
+
),
|
|
94
|
+
):
|
|
95
|
+
"""List all SSH keys for the current user."""
|
|
96
|
+
set_output_format(format)
|
|
97
|
+
client = get_client()
|
|
98
|
+
try:
|
|
99
|
+
keys = client.key_list()
|
|
100
|
+
print_output(keys, table_render_func=_render_ssh_keys_table)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
handle_error(e, "Error listing SSH keys")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .config import load_config, save_config, get_api_key, get_base_url
|
|
2
|
+
from .exceptions import handle_error
|
|
3
|
+
from .formatters import OutputFormat, set_output_format, get_output_format, print_output
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"load_config",
|
|
7
|
+
"save_config",
|
|
8
|
+
"get_api_key",
|
|
9
|
+
"get_base_url",
|
|
10
|
+
"handle_error",
|
|
11
|
+
"OutputFormat",
|
|
12
|
+
"set_output_format",
|
|
13
|
+
"get_output_format",
|
|
14
|
+
"print_output",
|
|
15
|
+
]
|
|
16
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import keyring
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".nodepick"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
9
|
+
|
|
10
|
+
KEYRING_SERVICE = "nodepick-cli"
|
|
11
|
+
KEYRING_API_KEY = "api_key"
|
|
12
|
+
|
|
13
|
+
_base_url_override: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_base_url_override(url: Optional[str]) -> None:
|
|
17
|
+
global _base_url_override
|
|
18
|
+
_base_url_override = url.rstrip("/") if url else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_config_dir() -> Path:
|
|
22
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
if CONFIG_FILE.exists():
|
|
24
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
25
|
+
return CONFIG_DIR
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config() -> Dict[str, Any]:
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return {}
|
|
31
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
32
|
+
try:
|
|
33
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
34
|
+
return json.load(f)
|
|
35
|
+
except Exception:
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_config(data: Dict[str, Any]) -> None:
|
|
40
|
+
get_config_dir()
|
|
41
|
+
config = load_config()
|
|
42
|
+
config.update(data)
|
|
43
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
44
|
+
json.dump(config, f, indent=2)
|
|
45
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _keyring_get(key_name: str) -> Optional[str]:
|
|
49
|
+
try:
|
|
50
|
+
return keyring.get_password(KEYRING_SERVICE, key_name) or None
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_api_key(override: Optional[str] = None) -> Optional[str]:
|
|
56
|
+
if override:
|
|
57
|
+
return override
|
|
58
|
+
# 1. Environment variable
|
|
59
|
+
env_key = os.getenv("NODEPICK_API_KEY")
|
|
60
|
+
if env_key:
|
|
61
|
+
return env_key
|
|
62
|
+
# 2. OS keyring (set via 'np auth configure')
|
|
63
|
+
return _keyring_get(KEYRING_API_KEY)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_base_url() -> str:
|
|
67
|
+
if _base_url_override:
|
|
68
|
+
return _base_url_override
|
|
69
|
+
# 1. Environment variable
|
|
70
|
+
env_url = os.getenv("NODEPICK_BASE_URL")
|
|
71
|
+
if env_url:
|
|
72
|
+
return env_url.rstrip("/")
|
|
73
|
+
# 2. Config file
|
|
74
|
+
return load_config().get("base_url", "https://api.nodepick.ai").rstrip("/")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import httpx
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from .formatters import get_output_format, OutputFormat
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
def handle_error(e: Exception, message: str = "An error occurred"):
|
|
10
|
+
if get_output_format() == OutputFormat.JSON:
|
|
11
|
+
err_obj = {"code": "error", "message": message}
|
|
12
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
13
|
+
try:
|
|
14
|
+
body = e.response.json()
|
|
15
|
+
if isinstance(body, dict):
|
|
16
|
+
inner_err = body.get("error")
|
|
17
|
+
if isinstance(inner_err, dict):
|
|
18
|
+
err_code = inner_err.get("code") or body.get("code") or "error"
|
|
19
|
+
err_msg = inner_err.get("message") or str(e)
|
|
20
|
+
err_obj = {"code": err_code, "message": err_msg}
|
|
21
|
+
if "details" in inner_err:
|
|
22
|
+
err_obj["details"] = inner_err["details"]
|
|
23
|
+
else:
|
|
24
|
+
err_code = body.get("code") or inner_err or "error"
|
|
25
|
+
err_msg = body.get("message") or inner_err or str(e)
|
|
26
|
+
err_obj = {"code": err_code, "message": err_msg}
|
|
27
|
+
if "details" in body and "details" not in err_obj:
|
|
28
|
+
err_obj["details"] = body["details"]
|
|
29
|
+
else:
|
|
30
|
+
err_obj = {"code": f"http_{e.response.status_code}", "message": str(e)}
|
|
31
|
+
except Exception:
|
|
32
|
+
err_obj = {"code": f"http_{e.response.status_code}", "message": str(e)}
|
|
33
|
+
else:
|
|
34
|
+
err_obj = {"code": "error", "message": str(e)}
|
|
35
|
+
console.print_json(data=err_obj)
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
|
|
38
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
39
|
+
status = e.response.status_code
|
|
40
|
+
err_msg = ""
|
|
41
|
+
try:
|
|
42
|
+
body = e.response.json()
|
|
43
|
+
if isinstance(body, dict):
|
|
44
|
+
inner_err = body.get("error")
|
|
45
|
+
if isinstance(inner_err, dict):
|
|
46
|
+
err_msg = inner_err.get("message") or ""
|
|
47
|
+
elif isinstance(inner_err, str):
|
|
48
|
+
err_msg = inner_err
|
|
49
|
+
else:
|
|
50
|
+
err_msg = body.get("message") or ""
|
|
51
|
+
details = body.get("details") or (inner_err.get("details") if isinstance(inner_err, dict) else None)
|
|
52
|
+
if details and isinstance(details, dict):
|
|
53
|
+
field_errs = details.get("fieldErrors")
|
|
54
|
+
if field_errs:
|
|
55
|
+
errs = [f"{k}: {', '.join(v)}" for k, v in field_errs.items() if v]
|
|
56
|
+
if errs:
|
|
57
|
+
err_msg += f" ({'; '.join(errs)})"
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
if err_msg:
|
|
62
|
+
console.print(f"[bold red]{message}:[/bold red] {err_msg}")
|
|
63
|
+
elif status == 404:
|
|
64
|
+
console.print("[bold red]Node not found[/bold red]")
|
|
65
|
+
elif status == 400:
|
|
66
|
+
console.print("[bold red]Invalid request[/bold red]")
|
|
67
|
+
elif status in (401, 403):
|
|
68
|
+
console.print("[bold red]Authentication failed.[/bold red] Check your API key or run 'np auth configure'.")
|
|
69
|
+
else:
|
|
70
|
+
console.print(f"[bold red]{message}:[/bold red] {e}")
|
|
71
|
+
else:
|
|
72
|
+
console.print(f"[bold red]{message}:[/bold red] {e}")
|
|
73
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
class OutputFormat(str, Enum):
|
|
11
|
+
TABLE = "table"
|
|
12
|
+
JSON = "json"
|
|
13
|
+
|
|
14
|
+
_current_format: OutputFormat = OutputFormat.TABLE
|
|
15
|
+
|
|
16
|
+
def set_output_format(fmt: OutputFormat) -> None:
|
|
17
|
+
global _current_format
|
|
18
|
+
_current_format = fmt
|
|
19
|
+
|
|
20
|
+
def get_output_format() -> OutputFormat:
|
|
21
|
+
return _current_format
|
|
22
|
+
|
|
23
|
+
def print_output(data: Any, table_render_func=None) -> None:
|
|
24
|
+
"""Print data according to active output format setting."""
|
|
25
|
+
if _current_format == OutputFormat.JSON:
|
|
26
|
+
if isinstance(data, (dict, list)):
|
|
27
|
+
console.print_json(data=data)
|
|
28
|
+
else:
|
|
29
|
+
console.print_json(data={"result": data})
|
|
30
|
+
else:
|
|
31
|
+
if table_render_func:
|
|
32
|
+
table_render_func(data)
|
|
33
|
+
elif isinstance(data, (dict, list)):
|
|
34
|
+
console.print_json(data=data)
|
|
35
|
+
else:
|
|
36
|
+
console.print(data)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
nodepick_cli/main.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from .commands.node import app as node_app
|
|
4
|
+
from .commands.ssh import app as ssh_app
|
|
5
|
+
from .commands.ai import app as ai_app
|
|
6
|
+
from .auth.login import app as auth_app
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def version_callback(value: bool):
|
|
11
|
+
if value:
|
|
12
|
+
typer.echo(f"nodepick CLI version {__version__}")
|
|
13
|
+
raise typer.Exit()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="np",
|
|
18
|
+
help="Nodepick CLI - Manage compute nodes, SSH keys, and authentication.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
from click.core import ParameterSource
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
import logging
|
|
27
|
+
|
|
28
|
+
@app.callback()
|
|
29
|
+
def main_callback(
|
|
30
|
+
version: bool = typer.Option(
|
|
31
|
+
None,
|
|
32
|
+
"--version",
|
|
33
|
+
"-v",
|
|
34
|
+
callback=version_callback,
|
|
35
|
+
is_eager=True,
|
|
36
|
+
help="Show the version and exit.",
|
|
37
|
+
),
|
|
38
|
+
debug: bool = typer.Option(
|
|
39
|
+
False,
|
|
40
|
+
"--debug", "-d",
|
|
41
|
+
help="Enable DEBUG logging output.",
|
|
42
|
+
),
|
|
43
|
+
):
|
|
44
|
+
if debug:
|
|
45
|
+
logger = logging.getLogger("nodepick")
|
|
46
|
+
logger.setLevel(logging.DEBUG)
|
|
47
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
48
|
+
handler.setLevel(logging.DEBUG)
|
|
49
|
+
formatter = logging.Formatter("[%(asctime)s] %(levelname)s [%(name)s]: %(message)s")
|
|
50
|
+
handler.setFormatter(formatter)
|
|
51
|
+
logger.addHandler(handler)
|
|
52
|
+
|
|
53
|
+
# Register command sub-groups
|
|
54
|
+
app.add_typer(auth_app, name="auth")
|
|
55
|
+
app.add_typer(node_app, name="node")
|
|
56
|
+
app.add_typer(ssh_app, name="ssh")
|
|
57
|
+
app.add_typer(ai_app, name="ai")
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
app()
|
|
61
|
+
|
|
62
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nodepick-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI tool for nodepick.ai Node orchestration
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.24.0
|
|
7
|
+
Requires-Dist: keyring>=25.0.0
|
|
8
|
+
Requires-Dist: nodepick
|
|
9
|
+
Requires-Dist: rich>=13.0.0
|
|
10
|
+
Requires-Dist: typer>=0.9.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# nodepick.ai CLI (`np`)
|
|
14
|
+
|
|
15
|
+
`np` is a command-line interface tool built with Python for managing Compute nodes.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### Recommended Method using Shell Installer (`curl`)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Install using auto-detection (uv -> pipx -> pip)
|
|
23
|
+
curl -sSL https://raw.githubusercontent.com/nodepick/developer/main/cli/install.sh | bash
|
|
24
|
+
|
|
25
|
+
# Install latest from Git repo
|
|
26
|
+
curl -sSL https://raw.githubusercontent.com/nodepick/developer/main/cli/install.sh | bash -s -- --mode git
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Recommended Method using (`uv`)
|
|
30
|
+
|
|
31
|
+
Using [`uv`](https://github.com/astral-sh/uv) is the recommended way to install and manage the `np` CLI tool globally in an isolated environment:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Install published package from PyPI
|
|
35
|
+
uv tool install nodepick-cli
|
|
36
|
+
|
|
37
|
+
# Or install from inside the cli directory:
|
|
38
|
+
uv tool install -e . --with ../sdk/python
|
|
39
|
+
|
|
40
|
+
# Or re-install
|
|
41
|
+
uv tool install -e . --with ../sdk/python --reinstall
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `pip` Method
|
|
45
|
+
|
|
46
|
+
Installing published package from PyPI:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install nodepick-cli
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
When installing from a local git repository, first install the local `nodepick` SDK dependency before installing `nodepick-cli`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Install from the "cli" folder using pip:
|
|
56
|
+
pip install -e ../sdk/python .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
[`nodepick.ai Documentation`](https://docs.nodepick.ai/cli-reference/overview)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
### Basic Usage
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# List all compute nodes (table view with SSH connect command)
|
|
69
|
+
np --version
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Authentication
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Configure API key (and optional base URL) securely in OS keyring
|
|
76
|
+
np auth configure
|
|
77
|
+
|
|
78
|
+
# Test API authentication status & organization details
|
|
79
|
+
np auth test
|
|
80
|
+
|
|
81
|
+
# Clear stored API key from OS keyring
|
|
82
|
+
np auth clear
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### SSH Key Management
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Register an SSH public key from a file
|
|
89
|
+
np ssh add -n "MBA 2026" --file ~/.ssh/id_ed25519.pub
|
|
90
|
+
|
|
91
|
+
# Register an SSH public key directly
|
|
92
|
+
np ssh add -n "Desktop" --key "ssh-ed25519 AAAAC3... user@host"
|
|
93
|
+
|
|
94
|
+
# List registered SSH keys
|
|
95
|
+
np ssh list
|
|
96
|
+
|
|
97
|
+
# Delete an SSH key by ID
|
|
98
|
+
np ssh delete <key_id>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Compute Nodes
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# List all compute nodes
|
|
105
|
+
np node list
|
|
106
|
+
|
|
107
|
+
# Create a new compute node
|
|
108
|
+
np node create --name "my-sandbox" --cpu 2 --memory 2048
|
|
109
|
+
|
|
110
|
+
# Get details & connection info for a node
|
|
111
|
+
np node get <node_id>
|
|
112
|
+
|
|
113
|
+
# Reboot a node
|
|
114
|
+
np node reboot <node_id>
|
|
115
|
+
|
|
116
|
+
# Shutdown a node
|
|
117
|
+
np node shutdown <node_id>
|
|
118
|
+
|
|
119
|
+
# Delete a node
|
|
120
|
+
np node delete <node_id>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### [BETA] AI & Model Context Protocol (MCP)
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Configure Antigravity to connect to a specific node MCP server over HTTP Stream
|
|
127
|
+
np ai mcp configure antigravity dev
|
|
128
|
+
|
|
129
|
+
# Configure Antigravity to connect to multiple nodes
|
|
130
|
+
np ai mcp configure antigravity dev prod
|
|
131
|
+
|
|
132
|
+
# Configure Antigravity for all available compute nodes
|
|
133
|
+
np ai mcp configure antigravity
|
|
134
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
nodepick_cli/__init__.py,sha256=1cnCOG00e0gyBfYwxDDkgcWh-piFvqHQ81M9jroSRu8,195
|
|
2
|
+
nodepick_cli/main.py,sha256=T6wMOIIcTQ62Ewo26m99em3jFxmzEm2wBKW_bNLUFSE,1528
|
|
3
|
+
nodepick_cli/auth/__init__.py,sha256=yLUKsSlfLejT3XJ_DgD3ZUnH56XAzFC9Bb9d2tXBrcU,42
|
|
4
|
+
nodepick_cli/auth/login.py,sha256=crBas_lttZ6gxdfOit9wwHeLUe98QO8NTBUfRmR98ow,3513
|
|
5
|
+
nodepick_cli/commands/__init__.py,sha256=--4wgPWi_foJaolSSQQEMEyOg7BQTyu9MZ3QNv-8-W0,144
|
|
6
|
+
nodepick_cli/commands/ai.py,sha256=vdq9auztildQQpYJ4LTMgb8eZiSR_Bgnp0joQFS9CEk,5949
|
|
7
|
+
nodepick_cli/commands/node.py,sha256=5JwrQEhYVFQvcBh803lKRm0Wif0etP1Ri9N5RT7u4u0,7631
|
|
8
|
+
nodepick_cli/commands/ssh.py,sha256=EySyuOfZ39iLyLGSp0z1saHdwXwfzFkm6GC2VvHxxjw,3214
|
|
9
|
+
nodepick_cli/core/__init__.py,sha256=3XPBu1_JhCi7yNnsWj-1E3OMV1cW0B4zq5iGzjernl0,401
|
|
10
|
+
nodepick_cli/core/config.py,sha256=3N_kjKNpPcy_dwceX7jecE7O7aBdqipv21XRxD5bw0A,1919
|
|
11
|
+
nodepick_cli/core/exceptions.py,sha256=Wy14t-7TEpCWXuSOPWVX_TEJEBxOb6cUiUMycbc4fNQ,3312
|
|
12
|
+
nodepick_cli/core/formatters.py,sha256=Y7Y6Ap7VOeBE4V5TXyGVcnyPVOB6_LW2lINtZ_lX2hQ,954
|
|
13
|
+
nodepick_cli-0.1.1.dist-info/METADATA,sha256=JxBhuwrZgAUl70EL8BpukdZaLsF3NUsXUMkQQXqHHOs,3002
|
|
14
|
+
nodepick_cli-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
nodepick_cli-0.1.1.dist-info/entry_points.txt,sha256=XFvdXTif7jy-Au3ufj_aDI4vzqL0Xy_PXaYav8_qcjM,45
|
|
16
|
+
nodepick_cli-0.1.1.dist-info/RECORD,,
|