gpu4agents 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. gpu4agents/__init__.py +5 -0
  2. gpu4agents/__main__.py +4 -0
  3. gpu4agents/agent/__init__.py +1 -0
  4. gpu4agents/agent/install.py +173 -0
  5. gpu4agents/agent/jobs.py +193 -0
  6. gpu4agents/agent/mcp.py +646 -0
  7. gpu4agents/agent/skill/SKILL.md +68 -0
  8. gpu4agents/cli/__init__.py +0 -0
  9. gpu4agents/cli/_common.py +131 -0
  10. gpu4agents/cli/account.py +330 -0
  11. gpu4agents/cli/agent.py +74 -0
  12. gpu4agents/cli/inspect.py +207 -0
  13. gpu4agents/cli/lifecycle.py +127 -0
  14. gpu4agents/cli/main.py +163 -0
  15. gpu4agents/cli/providers.py +96 -0
  16. gpu4agents/cli/run.py +38 -0
  17. gpu4agents/cli/setup.py +164 -0
  18. gpu4agents/cli/status.py +224 -0
  19. gpu4agents/cli/timer.py +143 -0
  20. gpu4agents/cli/validate.py +57 -0
  21. gpu4agents/core/__init__.py +0 -0
  22. gpu4agents/core/beacon.py +133 -0
  23. gpu4agents/core/config.py +647 -0
  24. gpu4agents/core/credentials.py +95 -0
  25. gpu4agents/core/durations.py +47 -0
  26. gpu4agents/core/engine.py +1335 -0
  27. gpu4agents/core/errors.py +94 -0
  28. gpu4agents/core/events.py +117 -0
  29. gpu4agents/core/execution.py +169 -0
  30. gpu4agents/core/health.py +62 -0
  31. gpu4agents/core/hosts.py +58 -0
  32. gpu4agents/core/model_inspect.py +368 -0
  33. gpu4agents/core/models.py +313 -0
  34. gpu4agents/core/paths.py +42 -0
  35. gpu4agents/core/policy.py +264 -0
  36. gpu4agents/core/state.py +406 -0
  37. gpu4agents/core/sweep.py +478 -0
  38. gpu4agents/core/tool_parsers.py +63 -0
  39. gpu4agents/providers/__init__.py +4 -0
  40. gpu4agents/providers/base.py +137 -0
  41. gpu4agents/providers/registry.py +63 -0
  42. gpu4agents/providers/vast/__init__.py +3 -0
  43. gpu4agents/providers/vast/client.py +255 -0
  44. gpu4agents/providers/vast/config.py +56 -0
  45. gpu4agents/providers/vast/mapper.py +292 -0
  46. gpu4agents/providers/vast/provider.py +438 -0
  47. gpu4agents/runtime/__init__.py +6 -0
  48. gpu4agents/runtime/base.py +184 -0
  49. gpu4agents/runtime/vllm.py +295 -0
  50. gpu4agents/watchdog/__init__.py +3 -0
  51. gpu4agents/watchdog/agent_body.py +959 -0
  52. gpu4agents/watchdog/builder.py +248 -0
  53. gpu4agents-0.5.0.dist-info/METADATA +157 -0
  54. gpu4agents-0.5.0.dist-info/RECORD +59 -0
  55. gpu4agents-0.5.0.dist-info/WHEEL +4 -0
  56. gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
  57. gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
  58. jambu_gpu/__init__.py +9 -0
  59. jambu_gpu/__main__.py +4 -0
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: gpu
3
+ description: Rent a temporary cloud GPU, serve an open-weights model on it behind an OpenAI-compatible API, run experiments or evals against it, and shut it down so it stops billing. Use when the user wants to run, test, benchmark or compare a HuggingFace model (Qwen, Llama, Mistral, ...) on a GPU, needs an LLM endpoint for an experiment, or asks about GPU instances gpu/gpu4agents created. Works through the `gpu` MCP tools (gpu_setup, gpu_run, ...) or the `gpu` CLI.
4
+ ---
5
+
6
+ # gpu: temporary GPUs for experiments
7
+
8
+ `gpu` (the `gpu4agents` package) rents a machine on Vast.ai, serves a model with vLLM behind an OpenAI-compatible API, and installs a watchdog on the machine that stops it once nothing has used it for `idle_timeout` (15 min by default). A machine you forget stops by itself, so billing ends even if this conversation does.
9
+
10
+ Each project directory has a `jambu.yaml` (what to rent and serve) and a `.gpu/` directory (what was rented). One project, one machine.
11
+
12
+ ## Two ways to drive it
13
+
14
+ - **MCP tools** (`gpu_*`), when the `gpu` MCP server is connected. Prefer them.
15
+ - **CLI**, from a shell: `gpu --json <command>` prints one JSON document on stdout; errors go to stderr as `error: ...` with a non-zero exit code. Run it from the project directory.
16
+
17
+ | Step | MCP tool | CLI |
18
+ | --- | --- | --- |
19
+ | create jambu.yaml | `gpu_init(model)` | `gpu init --model ORG/NAME` |
20
+ | check config and credentials | `gpu_validate` | `gpu --json validate` |
21
+ | see prices, rent nothing | `gpu_offers` | `gpu --json offers` |
22
+ | start / ensure ready | `gpu_setup` | `gpu --json setup` |
23
+ | endpoint without starting | `gpu_connection` | `gpu --json env` |
24
+ | run a job against it | `gpu_run(command=[...])` | `gpu run python job.py` |
25
+ | state, cost, idle time | `gpu_status` | `gpu --json status` |
26
+ | boot / server logs | `gpu_logs(source)` | `gpu --json logs --source onstart` |
27
+ | stop (keep disk) | `gpu_stop` | `gpu --json stop` |
28
+ | delete | `gpu_destroy(confirm=true)` | `gpu --json destroy --yes` |
29
+ | everything billing in the account | `gpu_ls` | `gpu --json ls` |
30
+
31
+ ## Rules: this spends the user's money
32
+
33
+ 1. **Ask before renting.** Before the first `gpu_setup` / `gpu setup` / `gpu run` in a conversation, show the model, the GPU and the price (`gpu_offers`) and the cap (`budget.max_hourly_cost_usd`), and get a yes. Never raise the budget in `jambu.yaml` without asking.
34
+ 2. **Setup is slow, and that is normal.** The first setup takes 5–15 minutes (image pull, model download); a bad host is replaced automatically. The MCP `gpu_setup` returns `status: "running"` while it works: call it again to keep waiting. From a shell, run `gpu --json setup` in the background with no timeout. Never start a second project, `--recreate`, or destroy because setup is slow. `gpu_status` (it shows `busy: true` during setup) and `gpu_logs(source="onstart")` show progress. If the user wants to give up, `gpu_stop` or `gpu_destroy` cancel the setup and destroy the half-booted machine.
35
+ 3. **Never handle the provider key.** Do not print, echo or ask for `VAST_API_KEY`. It is read from the environment, the project `.env` or `~/.gpu/credentials`. If it is missing (exit code 5), tell the user where to put it.
36
+ 4. **Clean up.** When the work is done, offer `gpu_stop` (cheap to resume: about a minute, same key) or `gpu_destroy` (nothing billed; next time rents and downloads again). Destroy only with the user's explicit yes. Before finishing, run `gpu_ls` and report anything still running.
37
+
38
+ ## A typical experiment
39
+
40
+ 1. If there is no `jambu.yaml`: `gpu_init(model=..., max_hourly_cost_usd=...)`. Size the GPU for the model: about 2 GB of VRAM per billion parameters in bf16, plus room for the KV cache (a 7–9B model wants 24 GB; a 0.5–3B model fits on 16–24 GB). `gpu inspect-model ORG/NAME` suggests VRAM and a tool-call parser.
41
+ 2. `gpu_validate`, then `gpu_offers`. Show the user the price; ask.
42
+ 3. `gpu_setup` until `status: "ready"`. You get `base_url`, `api_key`, `model`.
43
+ 4. Run the work:
44
+ - a script: `gpu_run(command=["python", "eval.py"])` (or `gpu run python eval.py`). It gets `OPENAI_BASE_URL`, `OPENAI_API_KEY` and `JAMBU_MODEL_ID` in its environment, which any OpenAI SDK reads, and the watchdog keeps the machine up while it runs. Write the script to read those variables instead of pasting the key into code.
45
+ - single requests: `curl "$base_url/chat/completions" -H "Authorization: Bearer $api_key"` with `"model": model`. Every request counts as activity.
46
+ 5. Report results and cost (`gpu_status` has `session_cost_usd`), then step 4 of the rules.
47
+
48
+ ## When something fails
49
+
50
+ Exit codes are the same for the CLI and the MCP tools (`exit_code` in the error):
51
+
52
+ | Code | Meaning | Do |
53
+ | --- | --- | --- |
54
+ | 2 | config error, or jambu.yaml changed since setup | fix it; `gpu_setup(recreate=true)` replaces a machine built from the old config |
55
+ | 3 | no machine / no endpoint | `gpu_setup` |
56
+ | 4, 8 | provider API error or timeout | retry with backoff |
57
+ | 5 | provider auth failed | the user fixes `VAST_API_KEY` |
58
+ | 6 | no capacity matches | retry later, or relax `compute.gpu` / the budget (ask first) |
59
+ | 7 | provisioning failed | retry; the failed machine is already destroyed |
60
+ | 10 | over budget | ask the user before raising it |
61
+ | 11 | model server did not start | the error carries the vLLM log tail: usually too little VRAM, a gated model without `HF_TOKEN`, or a bad `runtime` option. The machine was destroyed |
62
+ | 12 | another gpu command (a setup) holds the project | follow it with `gpu_setup`; `gpu_stop` / `gpu_destroy` cancel it |
63
+
64
+ - Requests fail with connection refused or a timeout: the machine was stopped for being idle. Call `gpu_setup` again (about a minute), take the **new** `base_url` from its output, and retry once.
65
+ - `401`: a new machine was rented and the key changed; take it from `gpu_connection`.
66
+ - A long job with quiet stretches longer than `idle_timeout`: `gpu_lock(duration="2h")`, then `gpu_unlock`.
67
+
68
+ Full contract: https://github.com/jambuai/gpu4agents/blob/main/docs/INTEGRATION.md
File without changes
@@ -0,0 +1,131 @@
1
+ """Shared CLI plumbing: config loading, output and error rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as jsonlib
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from ..core.config import RuntimeConfig, load_config
16
+ from ..core.engine import Engine
17
+ from ..core.errors import JambuError
18
+ from ..core.state import to_iso
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+ STATE_STYLES = {
24
+ "ready": "bold green",
25
+ "busy": "bold cyan",
26
+ "idle": "yellow",
27
+ "starting": "cyan",
28
+ "provisioning": "cyan",
29
+ "stopped": "dim",
30
+ "destroyed": "dim",
31
+ "failed": "bold red",
32
+ "unknown": "magenta",
33
+ "none": "dim",
34
+ }
35
+
36
+
37
+ class Context:
38
+ """Per-invocation state carried on the typer context object."""
39
+
40
+ def __init__(self) -> None:
41
+ self.config_path: Optional[Path] = None
42
+ self.json: bool = False
43
+ self.verbose: bool = False
44
+ self._config: Optional[RuntimeConfig] = None
45
+ self._engine: Optional[Engine] = None
46
+
47
+ def config(self) -> RuntimeConfig:
48
+ if self._config is None:
49
+ self._config = load_config(self.config_path)
50
+ return self._config
51
+
52
+ def engine(self) -> Engine:
53
+ if self._engine is None:
54
+ self._engine = Engine(self.config(), echo=self.echo)
55
+ return self._engine
56
+
57
+ def echo(self, message: str) -> None:
58
+ if self.json:
59
+ return
60
+ style = ""
61
+ if message.startswith("!"):
62
+ style = "yellow"
63
+ elif message.startswith("+"):
64
+ style = "green"
65
+ elif message.startswith("-"):
66
+ style = "red"
67
+ elif message.startswith(">"):
68
+ style = "cyan"
69
+ console.print(Text(message, style=style) if style else message)
70
+
71
+
72
+ def get_context(ctx: typer.Context) -> Context:
73
+ if ctx.obj is None:
74
+ ctx.obj = Context()
75
+ return ctx.obj
76
+
77
+
78
+ def fail(message: str, code: int = 1) -> None:
79
+ err_console.print(Text(f"error: {message}", style="bold red"))
80
+ raise typer.Exit(code)
81
+
82
+
83
+ def handle(exc: Exception) -> None:
84
+ """Render a JambuError the same way regardless of which provider raised it."""
85
+ if isinstance(exc, JambuError):
86
+ err_console.print(Text(f"error: {exc}", style="bold red"))
87
+ raise typer.Exit(getattr(exc, "exit_code", 1))
88
+ raise exc
89
+
90
+
91
+ def print_json(payload: Any) -> None:
92
+ sys.stdout.write(jsonlib.dumps(payload, indent=2, default=str) + "\n")
93
+
94
+
95
+ def state_text(state: str) -> Text:
96
+ return Text(state.upper(), style=STATE_STYLES.get(state, "white"))
97
+
98
+
99
+ def kv_table(title: str, rows: list[tuple[str, Any]]) -> Table:
100
+ table = Table(title=title, show_header=False, box=None, title_justify="left", pad_edge=False)
101
+ table.add_column("field", style="dim", no_wrap=True)
102
+ table.add_column("value")
103
+ for key, value in rows:
104
+ if value is None:
105
+ continue
106
+ table.add_row(key, value if isinstance(value, Text) else str(value))
107
+ return table
108
+
109
+
110
+ def fmt_time(ts: Optional[float]) -> Optional[str]:
111
+ return to_iso(ts) if ts else None
112
+
113
+
114
+ def fmt_money(value: Optional[float], suffix: str = "") -> Optional[str]:
115
+ if value is None:
116
+ return None
117
+ return f"${value:.3f}{suffix}"
118
+
119
+
120
+ def register_project(context: Context) -> None:
121
+ """Put this project in $GPU_HOME/projects.json so `gpu sweep` guards it.
122
+
123
+ Done before provisioning: a setup that dies halfway must still leave an
124
+ instance the sweep judges by this project's policy, not as an orphan.
125
+ """
126
+ from ..core.sweep import register_project as _register
127
+
128
+ try:
129
+ _register(context.config())
130
+ except OSError as exc:
131
+ context.echo(f"! could not register the project for `gpu sweep`: {exc}")
@@ -0,0 +1,330 @@
1
+ """`gpu ls` / `gpu sweep` - every jambu instance in the account, not just this project's."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from ..core import sweep as sweep_mod
13
+ from ..core.config import RuntimeConfig, find_config
14
+ from ..core.credentials import CredentialResolver
15
+ from ..core.durations import format_duration, parse_duration
16
+ from ..core.engine import Engine
17
+ from ..core.errors import ConfigError, JambuError
18
+ from ..core.events import EventLog
19
+ from ..providers.registry import load_builtin_providers
20
+ from . import timer
21
+ from ._common import Context, console, fmt_money, get_context, handle, print_json, state_text
22
+
23
+ DEFAULT_PROVIDER = "vast"
24
+
25
+
26
+ class Account:
27
+ """A provider handle that works with or without a project around it."""
28
+
29
+ def __init__(self, context: Context, provider_name: Optional[str] = None) -> None:
30
+ self.config: Optional[RuntimeConfig] = None
31
+ if context.config_path is not None or find_config() is not None:
32
+ self.config = context.config()
33
+ if self.config is not None:
34
+ project_dir = self.config.project_dir
35
+ profile = self.config.provider.profile
36
+ name = provider_name or self.config.provider.name
37
+ config = self.config
38
+ else:
39
+ # No project: a placeholder config only carries the provider name.
40
+ project_dir = Path.cwd()
41
+ profile = "default"
42
+ name = provider_name or DEFAULT_PROVIDER
43
+ config = RuntimeConfig.model_validate(
44
+ {"provider": {"name": name}, "model": {"id": "jambu/account"}}
45
+ )
46
+ self.credentials = account_credentials(project_dir, profile)
47
+ self.provider = make_provider(config, self.credentials, name)
48
+
49
+ def project_paths(self) -> list[Path]:
50
+ """Registered projects, plus the one this command runs in (ad hoc)."""
51
+ paths = sweep_mod.ProjectRegistry().paths()
52
+ if self.config is not None and self.config.source_path is not None:
53
+ here = Path(self.config.source_path).resolve()
54
+ if here not in paths:
55
+ paths.append(here)
56
+ return paths
57
+
58
+
59
+ def account_credentials(project_dir: Path, profile: str = "default") -> CredentialResolver:
60
+ """The environment, ./.env, ~/.gpu/credentials, then registered projects' .env.
61
+
62
+ The timer runs from ~ without the shell's environment; a key kept only in
63
+ a project's .env must still reach the account-wide orphan sweep.
64
+ """
65
+ extra = [
66
+ ("registered project .env", path.parent / ".env")
67
+ for path in sweep_mod.ProjectRegistry().paths()
68
+ ]
69
+ return CredentialResolver(project_dir, profile=profile, extra_sources=extra)
70
+
71
+
72
+ def make_provider(config: RuntimeConfig, credentials: CredentialResolver, name: str):
73
+ """Seam for tests: they swap in a FakeProvider here."""
74
+ return load_builtin_providers().get(config, credentials, name=name)
75
+
76
+
77
+ def make_engine(config: RuntimeConfig, echo=None) -> Engine:
78
+ """A project's own engine: its config, its credentials, its state."""
79
+ credentials = CredentialResolver(config.project_dir, profile=config.provider.profile)
80
+ provider = make_provider(config, credentials, config.provider.name)
81
+ return Engine(config, provider=provider, echo=echo, credentials=credentials)
82
+
83
+
84
+ def ls_command(
85
+ ctx: typer.Context,
86
+ provider: Optional[str] = typer.Option(
87
+ None, "--provider", help="Provider to list (default: jambu.yaml's, else vast)."
88
+ ),
89
+ ) -> None:
90
+ """List every jambu instance in the provider account."""
91
+ context = get_context(ctx)
92
+ try:
93
+ account = Account(context, provider)
94
+ instances = sweep_mod.list_jambu_instances(account.provider)
95
+ owners = sweep_mod.tracked_by_projects(account.project_paths())
96
+ except JambuError as exc:
97
+ handle(exc)
98
+ return
99
+ name = account.provider.name
100
+ tracked = {iid: path for (pname, iid), path in owners.items() if pname == name}
101
+ rows = sweep_mod.account_rows(instances, tracked)
102
+
103
+ if context.json:
104
+ print_json({"provider": name, "instances": rows})
105
+ return
106
+ if not rows:
107
+ console.print(Text(f"no jambu instances on {account.provider.display_name}", style="dim"))
108
+ return
109
+ table = Table(title=f"{account.provider.display_name} instances", title_justify="left", box=None)
110
+ for column in ("ID", "LABEL", "STATE", "GPU", "$/H", "AGE", "PROJECT"):
111
+ table.add_column(column)
112
+ for row in rows:
113
+ table.add_row(
114
+ row["instance_id"],
115
+ row["label"],
116
+ state_text(row["state"]),
117
+ row["gpu"] or "-",
118
+ fmt_money(row["hourly_cost_usd"]) or "-",
119
+ format_duration(row["age_seconds"]),
120
+ Text(row["project"], style="green") if row["tracked"] else Text("orphan", style="yellow"),
121
+ )
122
+ console.print(table)
123
+
124
+
125
+ def _seconds(flag: Optional[str], name: str) -> Optional[float]:
126
+ try:
127
+ return parse_duration(flag)
128
+ except ValueError as exc:
129
+ raise ConfigError(f"{name}: {exc}") from exc
130
+
131
+
132
+ def sweep_command(
133
+ ctx: typer.Context,
134
+ apply: bool = typer.Option(False, "--apply", help="Act. Without it the sweep only reports."),
135
+ orphan_max_age: str = typer.Option(
136
+ "24h", "--orphan-max-age", help="Stop an untracked running instance older than this."
137
+ ),
138
+ stopped_ttl: str = typer.Option(
139
+ "24h", "--stopped-ttl", help="Destroy an untracked instance stopped longer than this."
140
+ ),
141
+ provider: Optional[str] = typer.Option(
142
+ None, "--provider", help="Provider to look for orphans on (default: jambu.yaml's, else vast)."
143
+ ),
144
+ config: Optional[Path] = typer.Option(
145
+ None, "--config", "-c", help="Also guard this project for this run, registered or not."
146
+ ),
147
+ install_timer: bool = typer.Option(
148
+ False, "--install-timer", help="Write a systemd user timer that runs the sweep."
149
+ ),
150
+ every: str = typer.Option("5m", "--every", help="Timer period for --install-timer."),
151
+ uninstall_timer: bool = typer.Option(
152
+ False, "--uninstall-timer", help="Remove the systemd user timer."
153
+ ),
154
+ ) -> None:
155
+ """Guard every registered project, then clean up orphans (dry run by default).
156
+
157
+ Each project registered by `gpu setup` is guarded with its own jambu.yaml
158
+ and state, exactly like `gpu guard` in that directory. An instance no
159
+ project tracks is an orphan: it is stopped (never destroyed) once it has
160
+ run longer than --orphan-max-age, and destroyed once it has been seen
161
+ stopped longer than --stopped-ttl. Instances without a jambu- label are
162
+ never touched.
163
+ """
164
+ context = get_context(ctx)
165
+ if config is not None:
166
+ context.config_path = config
167
+ try:
168
+ if install_timer or uninstall_timer:
169
+ _timer(context, install_timer, every)
170
+ return
171
+ _sweep(context, apply, orphan_max_age, stopped_ttl, provider)
172
+ except JambuError as exc:
173
+ handle(exc)
174
+
175
+
176
+ def _require_file_credentials(context: Context) -> None:
177
+ """The unit runs without the shell's environment: refuse what it cannot see."""
178
+ checks: list[tuple[str, CredentialResolver, str]] = []
179
+ if context.config_path is not None or find_config() is not None:
180
+ cfg = context.config()
181
+ checks.append(
182
+ (
183
+ cfg.provider.name,
184
+ CredentialResolver(cfg.project_dir, profile=cfg.provider.profile),
185
+ str(cfg.source_path),
186
+ )
187
+ )
188
+ # The unit runs from ~ with no project: the orphan sweep's own lookup.
189
+ checks.append((DEFAULT_PROVIDER, account_credentials(Path.home()), "the orphan sweep"))
190
+ registry = load_builtin_providers()
191
+ for provider_name, resolver, who in checks:
192
+ for name in registry.get_class(provider_name).required_credentials:
193
+ if resolver.file_source(name):
194
+ continue
195
+ where = "only in your shell environment" if resolver.get(name) else "not set anywhere"
196
+ raise ConfigError(
197
+ f"{name} is {where}, so the timer could not use it for {who}: a systemd "
198
+ f"user unit does not inherit your shell. Put `{name}=...` in "
199
+ "~/.gpu/credentials (chmod 600), then re-run --install-timer."
200
+ )
201
+
202
+
203
+ def _timer(context: Context, install: bool, every: str) -> None:
204
+ if not install:
205
+ result = timer.uninstall_timer()
206
+ if context.json:
207
+ print_json(result)
208
+ return
209
+ for path in result["removed"]:
210
+ console.print(Text(f"- removed {path}", style="red"))
211
+ if not result["removed"]:
212
+ console.print(Text("= no timer installed", style="dim"))
213
+ console.print(f" then run: {result['disable']}")
214
+ return
215
+
216
+ every_s = _seconds(every, "--every")
217
+ if not every_s:
218
+ raise ConfigError("--every must be a positive duration, e.g. 5m")
219
+ registry = sweep_mod.ProjectRegistry()
220
+ project = None
221
+ if context.config_path is not None or find_config() is not None:
222
+ project = registry.register(context.config().source_path)
223
+ _require_file_credentials(context)
224
+ result = timer.install_timer(every_s)
225
+ # Units written before the registry carried one guard line per project.
226
+ for legacy in result["legacy_guard_configs"]:
227
+ if Path(legacy).is_file():
228
+ registry.register(Path(legacy))
229
+ result["project_config"] = str(project) if project else None
230
+ result["projects"] = [str(p) for p in registry.paths()]
231
+ if context.json:
232
+ print_json(result)
233
+ return
234
+ console.print(Text(f"+ wrote {result['service']}", style="green"))
235
+ console.print(Text(f"+ wrote {result['timer']}", style="green"))
236
+ if project:
237
+ console.print(Text(f"+ registered {project}", style="green"))
238
+ console.print(
239
+ f" every {format_duration(result['every_s'])}: `gpu sweep --apply`, guarding "
240
+ f"{len(result['projects'])} registered project(s) and cleaning up orphans"
241
+ )
242
+ console.print(Text(f"> enable it: {result['enable']}", style="cyan"))
243
+ console.print(
244
+ Text(
245
+ " user timers stop at logout unless lingering is on: loginctl enable-linger $USER",
246
+ style="dim",
247
+ )
248
+ )
249
+
250
+
251
+ def _sweep(
252
+ context: Context,
253
+ apply: bool,
254
+ orphan_max_age: str,
255
+ stopped_ttl: str,
256
+ provider_name: Optional[str],
257
+ ) -> None:
258
+ orphan_max_age_s = _seconds(orphan_max_age, "--orphan-max-age")
259
+ stopped_ttl_s = _seconds(stopped_ttl, "--stopped-ttl")
260
+ account = Account(context, provider_name)
261
+ registry = sweep_mod.ProjectRegistry()
262
+ registered = registry.paths()
263
+ # A project whose jambu.yaml is gone has nothing left to guard it by.
264
+ gone = [p for p in registered if not p.is_file()]
265
+ registry.remove(gone)
266
+ for path in gone:
267
+ context.echo(f"- unregistered {path} (config file is gone)")
268
+ paths = [p for p in account.project_paths() if p not in gone]
269
+
270
+ if not context.json and paths:
271
+ context.echo(f"> guarding {len(paths)} project(s)" + ("" if apply else " (dry run)"))
272
+ guards, owned = sweep_mod.guard_projects(
273
+ paths, lambda cfg: make_engine(cfg, echo=context.echo), apply=apply, echo=context.echo
274
+ )
275
+ name = account.provider.name
276
+ tracked = {iid for (pname, iid) in owned if pname == name}
277
+
278
+ if not context.json:
279
+ context.echo(
280
+ f"> sweeping {account.provider.display_name} for orphans: running > "
281
+ f"{format_duration(orphan_max_age_s) if orphan_max_age_s else 'never'} is stopped, "
282
+ f"stopped > {format_duration(stopped_ttl_s) if stopped_ttl_s else 'never'} is destroyed"
283
+ + ("" if apply else " (dry run)")
284
+ )
285
+ try:
286
+ result = sweep_mod.sweep(
287
+ account.provider,
288
+ orphan_max_age_s=orphan_max_age_s,
289
+ stopped_ttl_s=stopped_ttl_s,
290
+ apply=apply,
291
+ tracked=tracked,
292
+ events=[EventLog(sweep_mod.jambu_home() / "logs").bind(provider=name)],
293
+ echo=context.echo,
294
+ )
295
+ except JambuError as exc:
296
+ # The projects were guarded already; report that, then fail the run.
297
+ if context.json:
298
+ print_json(
299
+ {
300
+ "provider": name,
301
+ "error": str(exc),
302
+ "projects": [g.to_dict() for g in guards],
303
+ "unregistered": [str(p) for p in gone],
304
+ }
305
+ )
306
+ raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
307
+ raise
308
+ failed = any(a.error for a in result.actions) or any(g.error for g in guards)
309
+ if context.json:
310
+ payload = result.to_dict()
311
+ payload["projects"] = [g.to_dict() for g in guards]
312
+ payload["unregistered"] = [str(p) for p in gone]
313
+ print_json(payload)
314
+ if failed:
315
+ raise typer.Exit(1)
316
+ return
317
+ pending = [a for a in result.actions if a.action != "none"] + [
318
+ g for g in guards if (g.decision or {}).get("action", "none") != "none"
319
+ ]
320
+ if not pending:
321
+ console.print(
322
+ Text(
323
+ f"= {result.seen} jambu instance(s), {result.tracked} tracked, nothing to do",
324
+ style="green",
325
+ )
326
+ )
327
+ elif not apply:
328
+ console.print(Text(f"! {len(pending)} action(s) pending; re-run with --apply", style="yellow"))
329
+ if failed:
330
+ raise typer.Exit(1)
@@ -0,0 +1,74 @@
1
+ """`gpu mcp` and `gpu agent` - let coding agents drive gpu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ import typer
9
+ from rich.text import Text
10
+
11
+ from ..core.errors import JambuError
12
+ from ._common import console, get_context, handle, print_json
13
+
14
+ agent_app = typer.Typer(
15
+ help="Give Claude Code, Cursor or Claude Desktop the gpu skill and MCP tools.",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+
20
+ def mcp_command(
21
+ project: Optional[Path] = typer.Option(
22
+ None,
23
+ "--project",
24
+ "-p",
25
+ help="Default project directory for the tools (default: $GPU_PROJECT_DIR, else the cwd).",
26
+ ),
27
+ ) -> None:
28
+ """Run the local MCP server on stdio (started by the agent's client, not by hand)."""
29
+ from ..agent.mcp import Server
30
+
31
+ Server(project=project).serve()
32
+
33
+
34
+ @agent_app.command("install")
35
+ def install_command(
36
+ ctx: typer.Context,
37
+ target: List[str] = typer.Option(
38
+ ["claude"],
39
+ "--target",
40
+ "-t",
41
+ help="claude (Claude Code), cursor, desktop (Claude Desktop). Repeat for several.",
42
+ ),
43
+ global_scope: bool = typer.Option(
44
+ False, "--global", "-g", help="For every project of this user, not just this directory."
45
+ ),
46
+ directory: Path = typer.Option(
47
+ Path("."), "--dir", help="Project directory (default: the current one)."
48
+ ),
49
+ ) -> None:
50
+ """Install the gpu skill and register the gpu MCP server."""
51
+ from ..agent.install import install
52
+
53
+ context = get_context(ctx)
54
+ try:
55
+ result = install(list(target), directory, global_scope=global_scope)
56
+ except JambuError as exc:
57
+ handle(exc)
58
+ return
59
+ if context.json:
60
+ print_json(result)
61
+ return
62
+ for item in result["installed"]:
63
+ style = "dim" if item["action"] == "unchanged" else "green"
64
+ console.print(Text(f"+ {item['what']}: {item['path']} ({item['action']})", style=style))
65
+ for note in result["notes"]:
66
+ console.print(Text(f" {note}", style="cyan"))
67
+
68
+
69
+ @agent_app.command("skill")
70
+ def skill_command() -> None:
71
+ """Print the bundled SKILL.md (to copy into another agent's skill directory)."""
72
+ from ..agent.install import skill_text
73
+
74
+ print(skill_text(), end="")