nodepick-cli 0.1.3__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. nodepick_cli-0.1.4/CHANGELOG.md +27 -0
  2. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/PKG-INFO +11 -2
  3. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/README.md +10 -1
  4. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/pyproject.toml +1 -1
  5. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/__init__.py +1 -1
  6. nodepick_cli-0.1.4/src/nodepick_cli/commands/compute.py +117 -0
  7. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/network.py +6 -7
  8. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/node.py +31 -101
  9. nodepick_cli-0.1.4/src/nodepick_cli/commands/regions.py +83 -0
  10. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/main.py +11 -4
  11. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/test_cli.py +248 -23
  12. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/uv.lock +2 -2
  13. nodepick_cli-0.1.3/CHANGELOG.md +0 -17
  14. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/.gitignore +0 -0
  15. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/install.sh +0 -0
  16. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/auth/__init__.py +0 -0
  17. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/auth/login.py +0 -0
  18. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/__init__.py +0 -0
  19. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/ai.py +0 -0
  20. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/ssh.py +0 -0
  21. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/core/__init__.py +0 -0
  22. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/core/config.py +0 -0
  23. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/core/exceptions.py +0 -0
  24. {nodepick_cli-0.1.3 → nodepick_cli-0.1.4}/src/nodepick_cli/core/formatters.py +0 -0
@@ -0,0 +1,27 @@
1
+ # CHANGELOG.md
2
+
3
+ ## [0.1.4] - 2026-10-08
4
+
5
+ ### Added
6
+ - Added short option `-s` alias to `--storage` on `np node create`.
7
+ - Added `--region` (`-r`) option to `np node create` to specify the deployment region.
8
+ - Added `--host` option to `np node create` to deploy directly onto a specified compute Host ID.
9
+ - Added region validation against supported active regions for `np node create` and `np network create`.
10
+ - Added `np regions` command to list available compute regions.
11
+ - Added `np compute` top-level command for compute discovery with real-time pricing and consolidated RAM/Disk/CPU specs.
12
+ - Added `np --version` (`-v`) flag.
13
+ - Added `np ai mcp configure <agent> [nodes...]` command to configure MCP servers on AI agents (Antigravity, OpenCode).
14
+
15
+ ### Changed
16
+ - Changed authentication command from `np auth save` to `np auth configure`.
17
+ - Replaced `np find` and `np node find` with top-level `np compute`.
18
+ - Removed NAT configuration options from `np network` commands (outbound NAT enabled upstream by default).
19
+ - Updated `--region` help message on `np node create` and `np network create` to "Target region or datacenter (e.g. us-west-1, fmt1)".
20
+ - Cleaned option type formatting in `np node create` help output (removed redundant `[x>=...]` range tags).
21
+
22
+ ## [0.1.0] - 2026-07-31
23
+
24
+ ### Added
25
+ - Created `np` CLI tool in `cli/` using Typer and Rich.
26
+ - Added commands: `list`, `create`, `get`, `delete`, `shutdown`, `reboot`.
27
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nodepick-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Official Python CLI for nodepick.ai
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.24.0
@@ -65,8 +65,17 @@ Check the official [`Documentation`](https://docs.nodepick.ai/cli-reference/over
65
65
  ### Basic Usage
66
66
 
67
67
  ```bash
68
- # List all compute nodes (table view with SSH connect command)
68
+ # Check version
69
69
  np --version
70
+
71
+ # List available regions
72
+ np regions
73
+
74
+ # Deploy a compute node in a specific region
75
+ np node create --name dev-node --region us-west-1
76
+
77
+ # List compute nodes
78
+ np node list
70
79
  ```
71
80
 
72
81
  ### Authentication
@@ -53,8 +53,17 @@ Check the official [`Documentation`](https://docs.nodepick.ai/cli-reference/over
53
53
  ### Basic Usage
54
54
 
55
55
  ```bash
56
- # List all compute nodes (table view with SSH connect command)
56
+ # Check version
57
57
  np --version
58
+
59
+ # List available regions
60
+ np regions
61
+
62
+ # Deploy a compute node in a specific region
63
+ np node create --name dev-node --region us-west-1
64
+
65
+ # List compute nodes
66
+ np node list
58
67
  ```
59
68
 
60
69
  ### Authentication
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nodepick-cli"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Official Python CLI for nodepick.ai"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@ import importlib.metadata
3
3
  try:
4
4
  __version__ = importlib.metadata.version("nodepick-cli")
5
5
  except Exception:
6
- __version__ = "0.1.0"
6
+ __version__ = "0.1.4"
7
7
 
8
8
  from .main import app
9
9
 
@@ -0,0 +1,117 @@
1
+ import typer
2
+ from typing import Optional
3
+ from rich.console import Console
4
+ from rich.table import Table, Column
5
+ from .node import get_client
6
+ from ..core.exceptions import handle_error
7
+ from ..core.formatters import OutputFormat, set_output_format, print_output
8
+
9
+ console = Console()
10
+
11
+
12
+ def _render_compute_hosts_table(hosts):
13
+ if not hosts:
14
+ console.print("[yellow]No compute hosts found matching criteria.[/yellow]")
15
+ return
16
+
17
+ table = Table(
18
+ Column("Host ID", no_wrap=True),
19
+ "Status",
20
+ "Region",
21
+ "Datacenter",
22
+ "RAM/Disk/CPU",
23
+ "Price/hr ($)",
24
+ "Price/mo ($)",
25
+ )
26
+ for host in hosts:
27
+ # Show requested specs (RAM, CPU, and Disk) for which pricing was evaluated,
28
+ # rather than the compute host's total reported capacity.
29
+ req_cpu = host.get("requested_cpu")
30
+ if req_cpu is None:
31
+ cpu_info = host.get("cpu", {})
32
+ req_cpu = cpu_info.get("cores") if isinstance(cpu_info, dict) else cpu_info
33
+
34
+ req_mem = host.get("requested_memory_gb")
35
+ if req_mem is None:
36
+ req_mem = host.get("memory_gb")
37
+ elif isinstance(req_mem, (int, float)) and req_mem == int(req_mem):
38
+ req_mem = int(req_mem)
39
+
40
+ req_disk = host.get("requested_storage_gb")
41
+ if req_disk is None:
42
+ req_disk = host.get("storage_gb")
43
+
44
+ def _fmt(val):
45
+ if val is None:
46
+ return "?"
47
+ if isinstance(val, (int, float)):
48
+ return str(int(val)) if val == int(val) else str(val)
49
+ return str(val)
50
+
51
+ specs_str = f"{_fmt(req_mem)}/{_fmt(req_disk)}/{_fmt(req_cpu)}"
52
+
53
+ pricing = host.get("pricing") if isinstance(host.get("pricing"), dict) else {}
54
+ hourly = pricing.get("hourly") or pricing.get("hour") or host.get("estimated_hourly_cost_usd")
55
+ monthly = pricing.get("monthly") or host.get("estimated_monthly_cost_usd")
56
+
57
+ if hourly is not None:
58
+ try:
59
+ hourly_str = f"${float(hourly):.4f}"
60
+ except (ValueError, TypeError):
61
+ hourly_str = f"${hourly}"
62
+ else:
63
+ hourly_str = "N/A"
64
+
65
+ if monthly is not None:
66
+ try:
67
+ monthly_str = f"${float(monthly):.2f}"
68
+ except (ValueError, TypeError):
69
+ monthly_str = f"${monthly}"
70
+ else:
71
+ monthly_str = "N/A"
72
+
73
+ status_val = str(host.get("status") or "active")
74
+
75
+ table.add_row(
76
+ str(host.get("id") or "N/A"),
77
+ status_val,
78
+ str(host.get("region") or "N/A"),
79
+ str(host.get("datacenter") or "N/A"),
80
+ specs_str,
81
+ hourly_str,
82
+ monthly_str,
83
+ )
84
+ console.print(table)
85
+
86
+
87
+ def compute_command(
88
+ cpu: Optional[int] = typer.Option(None, "--cpu", "-c", help="Minimum CPU cores"),
89
+ memory_gb: Optional[float] = typer.Option(None, "--memory", "-m", help="Minimum RAM in GB"),
90
+ storage_gb: Optional[int] = typer.Option(None, "--storage", "-s", help="Minimum storage in GB"),
91
+ max_price: Optional[float] = typer.Option(None, "--max-price", "-p", help="Maximum monthly price in USD"),
92
+ region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by target region"),
93
+ datacenter: Optional[str] = typer.Option(None, "--datacenter", help="Filter by datacenter facility code"),
94
+ status: Optional[str] = typer.Option(None, "--status", help="Filter by host status (active, reservable, all)"),
95
+ format: OutputFormat = typer.Option(
96
+ OutputFormat.TABLE,
97
+ "--format", "-f",
98
+ help="Output format (table or json).",
99
+ case_sensitive=False,
100
+ ),
101
+ ):
102
+ """Find and discover available compute hosts matching specific hardware and pricing criteria."""
103
+ set_output_format(format)
104
+ client = get_client()
105
+ try:
106
+ hosts = client.find_compute(
107
+ min_cpu=cpu,
108
+ min_memory_gb=memory_gb,
109
+ min_storage_gb=storage_gb,
110
+ max_price=max_price,
111
+ region=region,
112
+ datacenter=datacenter,
113
+ status=status,
114
+ )
115
+ print_output(hosts, table_render_func=_render_compute_hosts_table)
116
+ except Exception as e:
117
+ handle_error(e, "Error finding compute hosts")
@@ -2,7 +2,7 @@ import typer
2
2
  from typing import Optional
3
3
  from rich.console import Console
4
4
  from rich.table import Table
5
- import nodepick
5
+ from ..commands.regions import validate_region
6
6
  from ..commands.node import get_client
7
7
  from ..core.exceptions import handle_error
8
8
  from ..core.formatters import OutputFormat, set_output_format, print_output
@@ -16,14 +16,13 @@ def _render_networks_table(networks):
16
16
  console.print("[yellow]No VPC networks found.[/yellow]")
17
17
  return
18
18
 
19
- table = Table("ID", "Name", "Subnet", "Region", "NAT")
19
+ table = Table("ID", "Name", "Subnet", "Region")
20
20
  for net in networks:
21
21
  table.add_row(
22
22
  str(net.get("id") or "N/A"),
23
23
  str(net.get("name") or "N/A"),
24
24
  str(net.get("subnet") or "N/A"),
25
25
  str(net.get("region") or "-"),
26
- "Enabled" if net.get("enableOutboundNat", True) else "Disabled",
27
26
  )
28
27
  console.print(table)
29
28
 
@@ -72,20 +71,20 @@ def network_list(
72
71
 
73
72
  @app.command("create")
74
73
  def network_create(
75
- name: str = typer.Argument(..., help="Alphanumeric name for the VPC network"),
76
- region: str = typer.Option(..., "--region", "-r", help="Target geographic region (e.g. us-west-1)"),
74
+ name: str = typer.Argument(..., help="Name for the VPC network (alphanumeric, dashes, and underscores)"),
75
+ region: str = typer.Option(..., "--region", "-r", help="Target region or datacenter (e.g. us-west-1, fmt1)"),
77
76
  description: Optional[str] = typer.Option(None, "--description", "-d", help="Optional description"),
78
- enable_nat: bool = typer.Option(True, "--nat/--no-nat", help="Enable outbound NAT gateway"),
79
77
  ):
80
78
  """Create a new VPC network."""
81
79
  client = get_client()
80
+ if region:
81
+ validate_region(client, region)
82
82
  try:
83
83
  console.print(f"[cyan]Creating VPC network '{name}'...[/cyan]")
84
84
  network = client.vpc_create(
85
85
  name=name,
86
86
  region=region,
87
87
  description=description,
88
- enable_outbound_nat=enable_nat,
89
88
  )
90
89
  vpc_id = network.get("id")
91
90
  console.print(f"[bold green]VPC network created successfully![/bold green] ID: {vpc_id}")
@@ -83,104 +83,7 @@ def _render_nodes_table(nodes, client=None):
83
83
  console.print(table)
84
84
 
85
85
 
86
- def _render_compute_hosts_table(hosts):
87
- if not hosts:
88
- console.print("[yellow]No compute hosts found matching criteria.[/yellow]")
89
- return
90
-
91
- table = Table(
92
- "Host ID",
93
- "Region",
94
- "Datacenter",
95
- "RAM (GB)",
96
- "Disk (GB)",
97
- "CPU (Cores)",
98
- "Price/hr ($)",
99
- "Price/mo ($)",
100
- )
101
- for host in hosts:
102
- # Show requested specs (RAM, CPU, and Disk) for which pricing was evaluated,
103
- # rather than the compute host's total reported capacity.
104
- req_cpu = host.get("requested_cpu")
105
- if req_cpu is None:
106
- cpu_info = host.get("cpu", {})
107
- req_cpu = cpu_info.get("cores") if isinstance(cpu_info, dict) else cpu_info
108
-
109
- req_mem = host.get("requested_memory_gb")
110
- if req_mem is None:
111
- req_mem = host.get("memory_gb")
112
- elif isinstance(req_mem, (int, float)) and req_mem == int(req_mem):
113
- req_mem = int(req_mem)
114
-
115
- req_disk = host.get("requested_storage_gb")
116
- if req_disk is None:
117
- req_disk = host.get("storage_gb")
118
-
119
- pricing = host.get("pricing") if isinstance(host.get("pricing"), dict) else {}
120
- hourly = pricing.get("hourly") or pricing.get("hour") or host.get("estimated_hourly_cost_usd")
121
- monthly = pricing.get("monthly") or host.get("estimated_monthly_cost_usd")
122
-
123
- if hourly is not None:
124
- try:
125
- hourly_str = f"${float(hourly):.4f}"
126
- except (ValueError, TypeError):
127
- hourly_str = f"${hourly}"
128
- else:
129
- hourly_str = "N/A"
130
-
131
- if monthly is not None:
132
- try:
133
- monthly_str = f"${float(monthly):.2f}"
134
- except (ValueError, TypeError):
135
- monthly_str = f"${monthly}"
136
- else:
137
- monthly_str = "N/A"
138
86
 
139
- table.add_row(
140
- str(host.get("id") or "N/A"),
141
- str(host.get("region") or "N/A"),
142
- str(host.get("datacenter") or "N/A"),
143
- str(req_mem if req_mem is not None else "N/A"),
144
- str(req_disk if req_disk is not None else "N/A"),
145
- str(req_cpu if req_cpu is not None else "N/A"),
146
- hourly_str,
147
- monthly_str,
148
- )
149
- console.print(table)
150
-
151
-
152
- @app.command("find")
153
- def node_find(
154
- cpu: Optional[int] = typer.Option(None, "--cpu", "-c", help="Minimum CPU cores"),
155
- memory_gb: Optional[float] = typer.Option(None, "--memory", "-m", help="Minimum RAM in GB"),
156
- storage_gb: Optional[int] = typer.Option(None, "--storage", "-s", help="Minimum storage in GB"),
157
- max_price: Optional[float] = typer.Option(None, "--max-price", "-p", help="Maximum monthly price in USD"),
158
- region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by target region"),
159
- datacenter: Optional[str] = typer.Option(None, "--datacenter", help="Filter by datacenter facility code"),
160
- status: str = typer.Option("active", "--status", help="Filter by host status (active, reserved, all)"),
161
- format: OutputFormat = typer.Option(
162
- OutputFormat.TABLE,
163
- "--format", "-f",
164
- help="Output format (table or json).",
165
- case_sensitive=False,
166
- ),
167
- ):
168
- """Find and discover available compute hosts matching specific hardware and pricing criteria."""
169
- set_output_format(format)
170
- client = get_client()
171
- try:
172
- hosts = client.find_compute(
173
- min_cpu=cpu,
174
- min_memory_gb=memory_gb,
175
- min_storage_gb=storage_gb,
176
- max_price=max_price,
177
- region=region,
178
- datacenter=datacenter,
179
- status=status,
180
- )
181
- print_output(hosts, table_render_func=_render_compute_hosts_table)
182
- except Exception as e:
183
- handle_error(e, "Error finding compute hosts")
184
87
 
185
88
 
186
89
  @app.command("boot")
@@ -197,16 +100,41 @@ def node_boot(
197
100
  handle_error(e, "Error booting node")
198
101
 
199
102
 
103
+ from .regions import validate_region, _render_regions_table
104
+
105
+
106
+ def _validate_cpu(val: int) -> int:
107
+ if val < 1:
108
+ raise typer.BadParameter("Number of vCPUs must be at least 1.")
109
+ return val
110
+
111
+
112
+ def _validate_memory(val: int) -> int:
113
+ if val < 1:
114
+ raise typer.BadParameter("Memory size must be at least 1 GB.")
115
+ return val
116
+
117
+
118
+ def _validate_storage(val: int) -> int:
119
+ if val < 10:
120
+ raise typer.BadParameter("Disk storage must be at least 10 GB.")
121
+ return val
122
+
123
+
200
124
  @app.command("create")
201
125
  def node_create(
202
126
  display_name: Optional[str] = typer.Option(None, "--name", "-n", help="Display name for the node"),
203
- cpu: int = typer.Option(1, "--cpu", "-c", min=1, help="Number of vCPUs (min: 1)"),
204
- memory: int = typer.Option(1, "--memory", "-m", min=1, help="Memory size in GB (min: 1, default: 1)"),
205
- storage_gb: Optional[int] = typer.Option(None, "--storage", min=10, help="Disk storage in GB (min: 10, default: 10)"),
127
+ cpu: int = typer.Option(1, "--cpu", "-c", callback=_validate_cpu, help="Number of vCPUs (min: 1)"),
128
+ memory: int = typer.Option(1, "--memory", "-m", callback=_validate_memory, help="Memory size in GB (min: 1)"),
129
+ storage_gb: int = typer.Option(10, "--storage", "-s", callback=_validate_storage, help="Disk storage in GB (min: 10)"),
206
130
  vpc: Optional[str] = typer.Option(None, "--vpc", help="VPC network ID or name to deploy node into"),
131
+ region: Optional[str] = typer.Option(None, "--region", "-r", help="Target region or datacenter (e.g. us-west-1, fmt1)"),
132
+ host_id: Optional[str] = typer.Option(None, "--host", "--host-id", "--system-id", help="Target Host ID to deploy onto"),
207
133
  ):
208
134
  """Deploy a new compute node."""
209
135
  client = get_client()
136
+ if region:
137
+ validate_region(client, region)
210
138
  try:
211
139
  console.print("[cyan]Creating node...[/cyan]")
212
140
  node = client.node_create(
@@ -215,8 +143,10 @@ def node_create(
215
143
  display_name=display_name,
216
144
  storage_gb=storage_gb,
217
145
  vpc=vpc,
146
+ region=region,
147
+ host_id=host_id,
218
148
  )
219
- node_id = node.get("vm_uuid")
149
+ node_id = node.get("vm_uuid") or node.get("id")
220
150
  console.print(f"[bold green]Node created successfully![/bold green] ID: {node_id}")
221
151
  except Exception as e:
222
152
  handle_error(e, "Error creating node")
@@ -0,0 +1,83 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from typing import Optional
5
+ import nodepick
6
+ from nodepick_cli.core.config import get_api_key, get_base_url
7
+ from nodepick_cli.core.formatters import OutputFormat, set_output_format, print_output
8
+ from nodepick_cli.core.exceptions import handle_error
9
+
10
+ console = Console()
11
+
12
+
13
+ def get_client() -> nodepick.NodePickClient:
14
+ key = get_api_key()
15
+ url = get_base_url()
16
+ if not key:
17
+ console.print(
18
+ "[yellow]No API key found.[/yellow] "
19
+ "Run [bold]np auth configure[/bold] to store your API key."
20
+ )
21
+ raise typer.Exit(1)
22
+ return nodepick.NodePickClient(api_key=key, base_url=url)
23
+
24
+
25
+ def validate_region(client, region: Optional[str]) -> None:
26
+ """Validate that region is among available regions if regions are returned by the API."""
27
+ if not region:
28
+ return
29
+ try:
30
+ raw_regions = client.get_available_regions()
31
+ if isinstance(raw_regions, list) and len(raw_regions) > 0:
32
+ supported = set()
33
+ for r in raw_regions:
34
+ if isinstance(r, dict):
35
+ if r.get("id"):
36
+ supported.add(str(r["id"]).lower())
37
+ for dc in r.get("datacenters") or []:
38
+ supported.add(str(dc).lower())
39
+ if supported and region.lower() not in supported:
40
+ sorted_ids = sorted(list({str(r.get("id")) for r in raw_regions if isinstance(r, dict) and r.get("id")}))
41
+ raise typer.BadParameter(
42
+ f"Unsupported region '{region}'. Supported regions are: {', '.join(sorted_ids)}"
43
+ )
44
+ except typer.BadParameter:
45
+ raise
46
+ except Exception:
47
+ # Ignore network/mock failures during pre-validation; backend API will enforce
48
+ pass
49
+
50
+
51
+ def _render_regions_table(regions):
52
+ if not regions:
53
+ console.print("[yellow]No regions found.[/yellow]")
54
+ return
55
+
56
+ table = Table("Region ID", "Name", "Datacenters", "Location")
57
+ for r in regions:
58
+ table.add_row(
59
+ str(r.get("id") or "N/A"),
60
+ str(r.get("name") or r.get("label") or "N/A"),
61
+ ", ".join(r.get("datacenters") or []) or "-",
62
+ str(r.get("location") or r.get("continent") or "-"),
63
+ )
64
+ console.print(table)
65
+
66
+
67
+ def regions_command(
68
+ status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter regions by status ('active' or 'reservable')"),
69
+ format: OutputFormat = typer.Option(
70
+ OutputFormat.TABLE,
71
+ "--format", "-f",
72
+ help="Output format (table or json).",
73
+ case_sensitive=False,
74
+ ),
75
+ ):
76
+ """List available compute regions."""
77
+ set_output_format(format)
78
+ client = get_client()
79
+ try:
80
+ regions = client.get_available_regions(status=status)
81
+ print_output(regions, table_render_func=_render_regions_table)
82
+ except Exception as e:
83
+ handle_error(e, "Error listing regions")
@@ -1,6 +1,8 @@
1
1
  import typer
2
2
  from rich.console import Console
3
- from .commands.node import app as node_app, node_find
3
+ from .commands.node import app as node_app
4
+ from .commands.compute import compute_command
5
+ from .commands.regions import regions_command
4
6
  from .commands.network import app as network_app
5
7
  from .commands.ssh import app as ssh_app
6
8
  from .commands.ai import app as ai_app
@@ -58,11 +60,16 @@ app.add_typer(network_app, name="network")
58
60
  app.add_typer(ssh_app, name="ssh")
59
61
  app.add_typer(ai_app, name="ai")
60
62
 
61
- # Shortcut: np find
63
+ # Commands: np compute, np regions
62
64
  app.command(
63
- "find",
65
+ "compute",
64
66
  help="Find and discover available compute hosts matching specific hardware and pricing criteria.",
65
- )(node_find)
67
+ )(compute_command)
68
+
69
+ app.command(
70
+ "regions",
71
+ help="List available compute regions.",
72
+ )(regions_command)
66
73
 
67
74
  if __name__ == "__main__":
68
75
  app()
@@ -32,6 +32,17 @@ class TestCliCommands(unittest.TestCase):
32
32
  self.assertEqual(result.exit_code, 0)
33
33
  self.assertIn("Manage Compute Nodes", result.output)
34
34
 
35
+ res_create = runner.invoke(app, ["node", "create", "--help"], env={"COLUMNS": "160"})
36
+ self.assertEqual(res_create.exit_code, 0)
37
+ self.assertIn("-s", res_create.output)
38
+ self.assertIn("--storage", res_create.output)
39
+ self.assertIn("Target region or datacenter (e.g. us-west-1, fmt1)", res_create.output)
40
+ self.assertIn("Number of vCPUs (min: 1) [default: 1]", res_create.output)
41
+ self.assertIn("Memory size in GB (min: 1) [default: 1]", res_create.output)
42
+ self.assertIn("Disk storage in GB (min: 10) [default: 10]", res_create.output)
43
+ self.assertNotIn("[x>=1]", res_create.output)
44
+ self.assertNotIn("[x>=10]", res_create.output)
45
+
35
46
  def test_node_get_help(self):
36
47
  result = runner.invoke(app, ["node", "get", "--help"])
37
48
  self.assertEqual(result.exit_code, 0)
@@ -338,8 +349,9 @@ class TestCliCommands(unittest.TestCase):
338
349
  self.assertIn("vpc-1", result.output)
339
350
  self.assertIn("prod-net", result.output)
340
351
  self.assertIn("10.0.1.0/24", result.output)
341
- # Verify AZ column is not present
352
+ # Verify AZ and NAT columns are not present
342
353
  self.assertNotIn(" AZ ", result.output)
354
+ self.assertNotIn(" NAT ", result.output)
343
355
 
344
356
  def test_network_create(self):
345
357
  from unittest.mock import patch, MagicMock
@@ -354,7 +366,15 @@ class TestCliCommands(unittest.TestCase):
354
366
  name="devnet",
355
367
  region="fmt1",
356
368
  description=None,
357
- enable_outbound_nat=True,
369
+ )
370
+
371
+ # Verify VPC name with dashes (e.g. dev-vpc)
372
+ result_dash = runner.invoke(app, ["network", "create", "dev-vpc", "--region", "fmt1"])
373
+ self.assertEqual(result_dash.exit_code, 0)
374
+ mock_client.vpc_create.assert_called_with(
375
+ name="dev-vpc",
376
+ region="fmt1",
377
+ description=None,
358
378
  )
359
379
 
360
380
  # Verify --az option is no longer supported
@@ -362,6 +382,11 @@ class TestCliCommands(unittest.TestCase):
362
382
  self.assertNotEqual(result_az.exit_code, 0)
363
383
  self.assertIn("No such option: --az", result_az.output)
364
384
 
385
+ # Verify --nat option is no longer supported
386
+ result_nat = runner.invoke(app, ["network", "create", "devnet", "--region", "fmt1", "--nat"])
387
+ self.assertNotEqual(result_nat.exit_code, 0)
388
+ self.assertIn("No such option: --nat", result_nat.output)
389
+
365
390
  def test_network_get(self):
366
391
  from unittest.mock import patch, MagicMock
367
392
  mock_client = MagicMock()
@@ -387,12 +412,13 @@ class TestCliCommands(unittest.TestCase):
387
412
  self.assertEqual(result.exit_code, 0)
388
413
  self.assertIn("VPC network vpc-123 deleted", result.output)
389
414
 
390
- def test_node_find_command(self):
415
+ def test_compute_command(self):
391
416
  from unittest.mock import patch, MagicMock
392
417
  mock_client = MagicMock()
393
418
  mock_client.find_compute.return_value = [
394
419
  {
395
420
  "id": "host-1",
421
+ "status": "active",
396
422
  "region": "fmt1",
397
423
  "datacenter": "facility-1",
398
424
  "memory_gb": 32,
@@ -406,38 +432,66 @@ class TestCliCommands(unittest.TestCase):
406
432
  "monthly": "10.95",
407
433
  "currency": "USD"
408
434
  }
435
+ },
436
+ {
437
+ "id": "host-2",
438
+ "status": "reservable",
439
+ "region": "fmt1",
440
+ "datacenter": "facility-2",
441
+ "memory_gb": 64,
442
+ "storage_gb": 1000,
443
+ "cpu": {"cores": 32},
444
+ "requested_cpu": 2,
445
+ "requested_memory_gb": 4,
446
+ "requested_storage_gb": 50,
447
+ "pricing": {
448
+ "hourly": "0.0200",
449
+ "monthly": "14.60",
450
+ "currency": "USD"
451
+ }
409
452
  }
410
453
  ]
411
- with patch("nodepick_cli.commands.node.get_client", return_value=mock_client):
412
- # Test 'np node find'
413
- result = runner.invoke(app, ["node", "find", "-c", "2", "-m", "4", "-s", "50", "-p", "50"], env={"COLUMNS": "160"})
454
+ with patch("nodepick_cli.commands.compute.get_client", return_value=mock_client):
455
+ # Test 'np compute'
456
+ result = runner.invoke(app, ["compute", "-c", "2", "-m", "4", "-s", "50", "-p", "50"], env={"COLUMNS": "160"})
414
457
  self.assertEqual(result.exit_code, 0)
415
458
  # Verify required columns are present in output:
416
- # host id, region, datacenter, ram, disk, cpu, price-per-hour, price-per-month
459
+ # host id (full), status, region, datacenter, consolidated specs (RAM/Disk/CPU), price-per-hour, price-per-month
417
460
  self.assertIn("Host ID", result.output)
461
+ self.assertIn("Status", result.output)
418
462
  self.assertIn("Region", result.output)
419
463
  self.assertIn("Datacenter", result.output)
420
- self.assertIn("RAM (GB)", result.output)
421
- self.assertIn("Disk (GB)", result.output)
422
- self.assertIn("CPU (Cores)", result.output)
464
+ self.assertIn("RAM/Disk/CPU", result.output)
423
465
  self.assertIn("Price/hr ($)", result.output)
424
466
  self.assertIn("Price/mo ($)", result.output)
425
467
  self.assertIn("host-1", result.output)
468
+ self.assertIn("host-2", result.output)
469
+ self.assertIn("active", result.output)
470
+ self.assertIn("reservable", result.output)
426
471
  self.assertIn("fmt1", result.output)
427
472
  self.assertIn("facility-1", result.output)
428
473
  self.assertIn("$0.0150", result.output)
429
474
  self.assertIn("$10.95", result.output)
430
475
 
431
- # Verify requested values (4, 50, 2) are displayed, NOT host capacity (32, 500, 16)
432
- self.assertIn("4", result.output)
433
- self.assertIn("50", result.output)
434
- self.assertIn("2", result.output)
476
+ # Verify consolidated requested specs "4/50/2" (4 GB RAM, 50 GB Disk, 2 CPU cores)
477
+ self.assertIn("4/50/2", result.output)
435
478
 
436
- # Test shortcut 'np find'
437
- res_shortcut = runner.invoke(app, ["find", "-c", "2", "-m", "4", "-s", "50"], env={"COLUMNS": "160"})
438
- self.assertEqual(res_shortcut.exit_code, 0)
439
- self.assertIn("Host ID", res_shortcut.output)
440
- self.assertIn("$0.0150", res_shortcut.output)
479
+ # Test filter with --status reservable
480
+ mock_client.find_compute.reset_mock()
481
+ res_reservable = runner.invoke(app, ["compute", "--status", "reservable"], env={"COLUMNS": "160"})
482
+ self.assertEqual(res_reservable.exit_code, 0)
483
+ mock_client.find_compute.assert_called_once()
484
+ self.assertEqual(mock_client.find_compute.call_args[1]["status"], "reservable")
485
+
486
+ # Verify 'np find' is no longer supported (renamed to 'np compute')
487
+ res_find = runner.invoke(app, ["find"])
488
+ self.assertNotEqual(res_find.exit_code, 0)
489
+ self.assertIn("No such command 'find'", res_find.output)
490
+
491
+ # Verify 'np node find' is no longer supported
492
+ res_node_find = runner.invoke(app, ["node", "find"])
493
+ self.assertNotEqual(res_node_find.exit_code, 0)
494
+ self.assertIn("No such command 'find'", res_node_find.output)
441
495
 
442
496
  def test_node_create_with_vpc_and_no_network_flag(self):
443
497
  from unittest.mock import patch, MagicMock
@@ -452,8 +506,10 @@ class TestCliCommands(unittest.TestCase):
452
506
  memory=1,
453
507
  cpu=1,
454
508
  display_name="my-node",
455
- storage_gb=None,
509
+ storage_gb=10,
456
510
  vpc="devnet",
511
+ region=None,
512
+ host_id=None,
457
513
  )
458
514
 
459
515
  # 2. Create with custom --memory 2
@@ -464,25 +520,194 @@ class TestCliCommands(unittest.TestCase):
464
520
  memory=2,
465
521
  cpu=1,
466
522
  display_name="my-node-2",
467
- storage_gb=None,
523
+ storage_gb=10,
468
524
  vpc=None,
525
+ region=None,
526
+ host_id=None,
469
527
  )
470
528
 
471
- # 3. Verify --network option no longer exists
529
+ # 3. Create with --region fmt1 and -r us-west-1
530
+ mock_client.node_create.reset_mock()
531
+ result_reg = runner.invoke(app, ["node", "create", "--name", "my-node-reg", "--region", "fmt1"])
532
+ self.assertEqual(result_reg.exit_code, 0)
533
+ mock_client.node_create.assert_called_once_with(
534
+ memory=1,
535
+ cpu=1,
536
+ display_name="my-node-reg",
537
+ storage_gb=10,
538
+ vpc=None,
539
+ region="fmt1",
540
+ host_id=None,
541
+ )
542
+
543
+ mock_client.node_create.reset_mock()
544
+ result_reg_short = runner.invoke(app, ["node", "create", "--name", "my-node-reg2", "-r", "us-west-1"])
545
+ self.assertEqual(result_reg_short.exit_code, 0)
546
+ mock_client.node_create.assert_called_once_with(
547
+ memory=1,
548
+ cpu=1,
549
+ display_name="my-node-reg2",
550
+ storage_gb=10,
551
+ vpc=None,
552
+ region="us-west-1",
553
+ host_id=None,
554
+ )
555
+
556
+ # 4. Create with --storage and short -s
557
+ mock_client.node_create.reset_mock()
558
+ result_storage = runner.invoke(app, ["node", "create", "--name", "my-node-stor", "--storage", "25"])
559
+ self.assertEqual(result_storage.exit_code, 0)
560
+ mock_client.node_create.assert_called_once_with(
561
+ memory=1,
562
+ cpu=1,
563
+ display_name="my-node-stor",
564
+ storage_gb=25,
565
+ vpc=None,
566
+ region=None,
567
+ host_id=None,
568
+ )
569
+
570
+ mock_client.node_create.reset_mock()
571
+ result_storage_short = runner.invoke(app, ["node", "create", "--name", "my-node-stor2", "-s", "30"])
572
+ self.assertEqual(result_storage_short.exit_code, 0)
573
+ mock_client.node_create.assert_called_once_with(
574
+ memory=1,
575
+ cpu=1,
576
+ display_name="my-node-stor2",
577
+ storage_gb=30,
578
+ vpc=None,
579
+ region=None,
580
+ host_id=None,
581
+ )
582
+
583
+ mock_client.node_create.reset_mock()
584
+ result_combo = runner.invoke(app, ["node", "create", "-n", "combo-node", "-c", "2", "-m", "4", "-s", "50", "-r", "us-west-1"])
585
+ self.assertEqual(result_combo.exit_code, 0)
586
+ mock_client.node_create.assert_called_once_with(
587
+ memory=4,
588
+ cpu=2,
589
+ display_name="combo-node",
590
+ storage_gb=50,
591
+ vpc=None,
592
+ region="us-west-1",
593
+ host_id=None,
594
+ )
595
+
596
+ # 5. Verify --network option no longer exists
472
597
  result_invalid = runner.invoke(app, ["node", "create", "--network", "public"])
473
598
  self.assertNotEqual(result_invalid.exit_code, 0)
474
599
  self.assertIn("No such option: --network", result_invalid.output)
475
600
 
476
- # 4. Verify min constraints in CLI (cpu >= 1, memory >= 1 GB, storage >= 10 GB)
601
+ # 6. Verify min constraints in CLI (cpu >= 1, memory >= 1 GB, storage >= 10 GB)
477
602
  res_bad_cpu = runner.invoke(app, ["node", "create", "--cpu", "0"])
478
603
  self.assertNotEqual(res_bad_cpu.exit_code, 0)
479
604
 
605
+ res_bad_storage_short = runner.invoke(app, ["node", "create", "-s", "5"])
606
+ self.assertNotEqual(res_bad_storage_short.exit_code, 0)
607
+
608
+ def test_node_create_with_specific_host_id(self):
609
+ from unittest.mock import patch, MagicMock
610
+ mock_client = MagicMock()
611
+ mock_client.node_create.return_value = {"vm_uuid": "node-pinned-123"}
612
+ with patch("nodepick_cli.commands.node.get_client", return_value=mock_client):
613
+ # 1. Using --host
614
+ result = runner.invoke(app, ["node", "create", "--name", "pinned-node", "--host", "39e77410-ddf4-44aa-bbcc-1234567890ab"])
615
+ self.assertEqual(result.exit_code, 0)
616
+ self.assertIn("Node created successfully", result.output)
617
+ mock_client.node_create.assert_called_once_with(
618
+ memory=1,
619
+ cpu=1,
620
+ display_name="pinned-node",
621
+ storage_gb=10,
622
+ vpc=None,
623
+ region=None,
624
+ host_id="39e77410-ddf4-44aa-bbcc-1234567890ab",
625
+ )
626
+
627
+ # 2. Using --host-id
628
+ mock_client.node_create.reset_mock()
629
+ result_host_id = runner.invoke(app, ["node", "create", "--name", "pinned-2", "--host-id", "custom-host-id"])
630
+ self.assertEqual(result_host_id.exit_code, 0)
631
+ mock_client.node_create.assert_called_once_with(
632
+ memory=1,
633
+ cpu=1,
634
+ display_name="pinned-2",
635
+ storage_gb=10,
636
+ vpc=None,
637
+ region=None,
638
+ host_id="custom-host-id",
639
+ )
640
+
641
+ # 3. Using --system-id
642
+ mock_client.node_create.reset_mock()
643
+ result_sys_id = runner.invoke(app, ["node", "create", "--name", "pinned-3", "--system-id", "sys-host-id"])
644
+ self.assertEqual(result_sys_id.exit_code, 0)
645
+ mock_client.node_create.assert_called_once_with(
646
+ memory=1,
647
+ cpu=1,
648
+ display_name="pinned-3",
649
+ storage_gb=10,
650
+ vpc=None,
651
+ region=None,
652
+ host_id="sys-host-id",
653
+ )
654
+
480
655
  res_bad_mem = runner.invoke(app, ["node", "create", "--memory", "0"])
481
656
  self.assertNotEqual(res_bad_mem.exit_code, 0)
482
657
 
483
658
  res_bad_storage = runner.invoke(app, ["node", "create", "--storage", "5"])
484
659
  self.assertNotEqual(res_bad_storage.exit_code, 0)
485
660
 
661
+ def test_node_create_unsupported_region(self):
662
+ from unittest.mock import patch, MagicMock
663
+ mock_client = MagicMock()
664
+ mock_client.get_available_regions.return_value = [
665
+ {"id": "us-west-1", "name": "US West", "datacenters": ["fmt1"], "status": "active"},
666
+ {"id": "us-east-1", "name": "US East", "datacenters": ["iad1"], "status": "active"},
667
+ ]
668
+ with patch("nodepick_cli.commands.node.get_client", return_value=mock_client):
669
+ result = runner.invoke(app, ["node", "create", "--region", "unsupported-region"])
670
+ self.assertNotEqual(result.exit_code, 0)
671
+ self.assertIn("Unsupported region 'unsupported-region'", result.output)
672
+ self.assertIn("Supported regions", result.output)
673
+ self.assertIn("us-east-1", result.output)
674
+
675
+ def test_network_create_unsupported_region(self):
676
+ from unittest.mock import patch, MagicMock
677
+ mock_client = MagicMock()
678
+ mock_client.get_available_regions.return_value = [
679
+ {"id": "us-west-1", "name": "US West", "datacenters": ["fmt1"], "status": "active"},
680
+ ]
681
+ with patch("nodepick_cli.commands.network.get_client", return_value=mock_client):
682
+ result = runner.invoke(app, ["network", "create", "test-vpc", "--region", "unsupported-region"])
683
+ self.assertNotEqual(result.exit_code, 0)
684
+ self.assertIn("Unsupported region 'unsupported-region'", result.output)
685
+
686
+ def test_top_level_regions_command(self):
687
+ from unittest.mock import patch, MagicMock
688
+ mock_client = MagicMock()
689
+ mock_client.get_available_regions.return_value = [
690
+ {
691
+ "id": "us-west-1",
692
+ "name": "US West (Silicon Valley)",
693
+ "datacenters": ["fmt1"],
694
+ "location": "Silicon Valley",
695
+ }
696
+ ]
697
+ with patch("nodepick_cli.commands.regions.get_client", return_value=mock_client):
698
+ # Test top-level np regions
699
+ res = runner.invoke(app, ["regions"])
700
+ self.assertEqual(res.exit_code, 0)
701
+ self.assertIn("us-west-1", res.output)
702
+ self.assertIn("fmt1", res.output)
703
+ self.assertIn("Silicon Valley", res.output)
704
+ self.assertNotIn("Status", res.output)
705
+
706
+ # Verify np node regions is no longer a command
707
+ res_node_regions = runner.invoke(app, ["node", "regions"])
708
+ self.assertNotEqual(res_node_regions.exit_code, 0)
709
+ self.assertIn("No such command", res_node_regions.output)
710
+
486
711
  def test_node_attach_and_detach_ip_ipv4(self):
487
712
  from unittest.mock import patch, MagicMock
488
713
  mock_client = MagicMock()
@@ -514,7 +514,7 @@ wheels = [
514
514
 
515
515
  [[package]]
516
516
  name = "nodepick"
517
- version = "0.1.2"
517
+ version = "0.1.4"
518
518
  source = { editable = "../sdk/python" }
519
519
  dependencies = [
520
520
  { name = "httpx" },
@@ -529,7 +529,7 @@ requires-dist = [
529
529
 
530
530
  [[package]]
531
531
  name = "nodepick-cli"
532
- version = "0.1.2"
532
+ version = "0.1.4"
533
533
  source = { editable = "." }
534
534
  dependencies = [
535
535
  { name = "httpx" },
@@ -1,17 +0,0 @@
1
- # CHANGELOG.md
2
-
3
- ## [Unreleased]
4
-
5
- ### Changed
6
- - Changed authentication command from `np auth save` to `np auth configure`.
7
-
8
- ### Added
9
- - Added `np --version` (`-v`) flag.
10
- - Added `np ai mcp configure <agent> [nodes...]` command to configure MCP servers on AI agents (such as Antigravity) over HTTP Stream with self-signed certificate verification bypassed.
11
-
12
- ## [0.1.0] - 2026-07-31
13
-
14
- ### Added
15
- - Created `np` CLI tool in `cli/` using Typer and Rich.
16
- - Added commands: `list`, `create`, `get`, `delete`, `shutdown`, `reboot`.
17
-
File without changes
File without changes