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.
@@ -0,0 +1,11 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version("nodepick-cli")
5
+ except Exception:
6
+ __version__ = "0.1.0"
7
+
8
+ from .main import app
9
+
10
+ __all__ = ["app", "__version__"]
11
+
@@ -0,0 +1,3 @@
1
+ from .login import app
2
+
3
+ __all__ = ["app"]
@@ -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,8 @@
1
+ from .node import app as node_app
2
+ from .ssh import app as ssh_app
3
+ from .ai import app as ai_app
4
+
5
+ __all__ = ["node_app", "ssh_app", "ai_app"]
6
+
7
+
8
+
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ np = nodepick_cli.main:app