herds 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. herds/__init__.py +51 -0
  2. herds/cli/__init__.py +491 -0
  3. herds/config.py +170 -0
  4. herds/control/__init__.py +810 -0
  5. herds/control/store.py +567 -0
  6. herds/daemon/__init__.py +258 -0
  7. herds/daemon/__main__.py +6 -0
  8. herds/daemon/executor.py +330 -0
  9. herds/daemon/files.py +79 -0
  10. herds/daemon/images.py +106 -0
  11. herds/daemon/machine.py +69 -0
  12. herds/daemon/metrics.py +57 -0
  13. herds/host.py +369 -0
  14. herds/protocol.py +259 -0
  15. herds/relay.py +633 -0
  16. herds/sdk/__init__.py +28 -0
  17. herds/sdk/app.py +155 -0
  18. herds/sdk/client.py +179 -0
  19. herds/sdk/image.py +62 -0
  20. herds/sdk/mac.py +171 -0
  21. herds/sdk/sandbox.py +163 -0
  22. herds/sdk/secret.py +46 -0
  23. herds/sdk/volume.py +31 -0
  24. herds/skill.py +74 -0
  25. herds/web_dist/404.html +1 -0
  26. herds/web_dist/__next.__PAGE__.txt +9 -0
  27. herds/web_dist/__next._full.txt +22 -0
  28. herds/web_dist/__next._head.txt +5 -0
  29. herds/web_dist/__next._index.txt +8 -0
  30. herds/web_dist/__next._tree.txt +4 -0
  31. herds/web_dist/_next/static/chunks/02zcztjkk52mq.js +1 -0
  32. herds/web_dist/_next/static/chunks/05e83anyrmnuv.js +1 -0
  33. herds/web_dist/_next/static/chunks/0755w0rpp670_.js +1 -0
  34. herds/web_dist/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  35. herds/web_dist/_next/static/chunks/0i36h7rkvi9ay.js +1 -0
  36. herds/web_dist/_next/static/chunks/0iyrc8fu-0ayb.js +9 -0
  37. herds/web_dist/_next/static/chunks/0pq0d6vtp3kvw.js +1 -0
  38. herds/web_dist/_next/static/chunks/0tumh1559hcih.js +1 -0
  39. herds/web_dist/_next/static/chunks/120ol6mue7vp2.js +1 -0
  40. herds/web_dist/_next/static/chunks/1gn-yoma2aujf.js +5 -0
  41. herds/web_dist/_next/static/chunks/1op8bvg-0q6tl.js +1 -0
  42. herds/web_dist/_next/static/chunks/1pn5tud4t835x.js +9 -0
  43. herds/web_dist/_next/static/chunks/1sfqbf5qbd8y-.js +0 -0
  44. herds/web_dist/_next/static/chunks/248b_461ikafx.js +1 -0
  45. herds/web_dist/_next/static/chunks/2c-aykqsty-dq.js +1 -0
  46. herds/web_dist/_next/static/chunks/2j4_bxbfhamp6.js +1 -0
  47. herds/web_dist/_next/static/chunks/2sp6kvnbpdi-p.js +31 -0
  48. herds/web_dist/_next/static/chunks/2vmk9ipzrtj92.js +4 -0
  49. herds/web_dist/_next/static/chunks/37gl9pjlkkam1.js +1 -0
  50. herds/web_dist/_next/static/chunks/3aspsawddjes8.js +2 -0
  51. herds/web_dist/_next/static/chunks/3iww1zdedzdwz.js +1 -0
  52. herds/web_dist/_next/static/chunks/3j_4r2-hqycta.js +1 -0
  53. herds/web_dist/_next/static/chunks/3oi-7u0vpjh9h.js +1 -0
  54. herds/web_dist/_next/static/chunks/3sqbodmsyaqkm.js +1 -0
  55. herds/web_dist/_next/static/chunks/3v-rqdys4eu0a.js +0 -0
  56. herds/web_dist/_next/static/chunks/413vhtqaffkli.css +3 -0
  57. herds/web_dist/_next/static/chunks/415in7dhfqo3x.js +1 -0
  58. herds/web_dist/_next/static/chunks/44nsp8_bkorwp.js +0 -0
  59. herds/web_dist/_next/static/chunks/turbopack-0oar_l0ke-f9y.js +1 -0
  60. herds/web_dist/_next/static/media/GeistMono_Variable.p.3ms9vq719j3f8.woff2 +0 -0
  61. herds/web_dist/_next/static/media/Geist_Variable-s.p.0mrjj4bg00-he.woff2 +0 -0
  62. herds/web_dist/_next/static/vje3Zo17YIetwDl0k5oV4/_buildManifest.js +11 -0
  63. herds/web_dist/_next/static/vje3Zo17YIetwDl0k5oV4/_clientMiddlewareManifest.js +1 -0
  64. herds/web_dist/_next/static/vje3Zo17YIetwDl0k5oV4/_ssgManifest.js +1 -0
  65. herds/web_dist/_not-found/__next._full.txt +18 -0
  66. herds/web_dist/_not-found/__next._head.txt +5 -0
  67. herds/web_dist/_not-found/__next._index.txt +8 -0
  68. herds/web_dist/_not-found/__next._not-found.__PAGE__.txt +5 -0
  69. herds/web_dist/_not-found/__next._not-found.txt +5 -0
  70. herds/web_dist/_not-found/__next._tree.txt +2 -0
  71. herds/web_dist/_not-found.html +1 -0
  72. herds/web_dist/_not-found.txt +18 -0
  73. herds/web_dist/dashboard/__next._full.txt +24 -0
  74. herds/web_dist/dashboard/__next._head.txt +5 -0
  75. herds/web_dist/dashboard/__next._index.txt +8 -0
  76. herds/web_dist/dashboard/__next._tree.txt +4 -0
  77. herds/web_dist/dashboard/__next.dashboard.__PAGE__.txt +9 -0
  78. herds/web_dist/dashboard/__next.dashboard.txt +5 -0
  79. herds/web_dist/dashboard.html +1 -0
  80. herds/web_dist/dashboard.txt +24 -0
  81. herds/web_dist/index.html +9 -0
  82. herds/web_dist/index.txt +22 -0
  83. herds/web_dist/login/__next._full.txt +24 -0
  84. herds/web_dist/login/__next._head.txt +5 -0
  85. herds/web_dist/login/__next._index.txt +8 -0
  86. herds/web_dist/login/__next._tree.txt +4 -0
  87. herds/web_dist/login/__next.login.__PAGE__.txt +9 -0
  88. herds/web_dist/login/__next.login.txt +5 -0
  89. herds/web_dist/login.html +1 -0
  90. herds/web_dist/login.txt +24 -0
  91. herds/web_dist/machine/__next._full.txt +24 -0
  92. herds/web_dist/machine/__next._head.txt +5 -0
  93. herds/web_dist/machine/__next._index.txt +8 -0
  94. herds/web_dist/machine/__next._tree.txt +4 -0
  95. herds/web_dist/machine/__next.machine.__PAGE__.txt +9 -0
  96. herds/web_dist/machine/__next.machine.txt +5 -0
  97. herds/web_dist/machine.html +1 -0
  98. herds/web_dist/machine.txt +24 -0
  99. herds/web_dist/machines/__next._full.txt +24 -0
  100. herds/web_dist/machines/__next._head.txt +5 -0
  101. herds/web_dist/machines/__next._index.txt +8 -0
  102. herds/web_dist/machines/__next._tree.txt +4 -0
  103. herds/web_dist/machines/__next.machines.__PAGE__.txt +9 -0
  104. herds/web_dist/machines/__next.machines.txt +5 -0
  105. herds/web_dist/machines.html +1 -0
  106. herds/web_dist/machines.txt +24 -0
  107. herds/web_dist/runs/__next._full.txt +24 -0
  108. herds/web_dist/runs/__next._head.txt +5 -0
  109. herds/web_dist/runs/__next._index.txt +8 -0
  110. herds/web_dist/runs/__next._tree.txt +4 -0
  111. herds/web_dist/runs/__next.runs.__PAGE__.txt +9 -0
  112. herds/web_dist/runs/__next.runs.txt +5 -0
  113. herds/web_dist/runs.html +1 -0
  114. herds/web_dist/runs.txt +24 -0
  115. herds/web_dist/sandbox/__next._full.txt +24 -0
  116. herds/web_dist/sandbox/__next._head.txt +5 -0
  117. herds/web_dist/sandbox/__next._index.txt +8 -0
  118. herds/web_dist/sandbox/__next._tree.txt +4 -0
  119. herds/web_dist/sandbox/__next.sandbox.__PAGE__.txt +9 -0
  120. herds/web_dist/sandbox/__next.sandbox.txt +5 -0
  121. herds/web_dist/sandbox.html +1 -0
  122. herds/web_dist/sandbox.txt +24 -0
  123. herds/web_dist/sandboxes/__next._full.txt +24 -0
  124. herds/web_dist/sandboxes/__next._head.txt +5 -0
  125. herds/web_dist/sandboxes/__next._index.txt +8 -0
  126. herds/web_dist/sandboxes/__next._tree.txt +4 -0
  127. herds/web_dist/sandboxes/__next.sandboxes.__PAGE__.txt +9 -0
  128. herds/web_dist/sandboxes/__next.sandboxes.txt +5 -0
  129. herds/web_dist/sandboxes.html +1 -0
  130. herds/web_dist/sandboxes.txt +24 -0
  131. herds/web_dist/secrets/__next._full.txt +24 -0
  132. herds/web_dist/secrets/__next._head.txt +5 -0
  133. herds/web_dist/secrets/__next._index.txt +8 -0
  134. herds/web_dist/secrets/__next._tree.txt +4 -0
  135. herds/web_dist/secrets/__next.secrets.__PAGE__.txt +9 -0
  136. herds/web_dist/secrets/__next.secrets.txt +5 -0
  137. herds/web_dist/secrets.html +1 -0
  138. herds/web_dist/secrets.txt +24 -0
  139. herds/web_dist/settings/__next._full.txt +24 -0
  140. herds/web_dist/settings/__next._head.txt +5 -0
  141. herds/web_dist/settings/__next._index.txt +8 -0
  142. herds/web_dist/settings/__next._tree.txt +4 -0
  143. herds/web_dist/settings/__next.settings.__PAGE__.txt +9 -0
  144. herds/web_dist/settings/__next.settings.txt +5 -0
  145. herds/web_dist/settings.html +5 -0
  146. herds/web_dist/settings.txt +24 -0
  147. herds/web_dist/signup/__next._full.txt +24 -0
  148. herds/web_dist/signup/__next._head.txt +5 -0
  149. herds/web_dist/signup/__next._index.txt +8 -0
  150. herds/web_dist/signup/__next._tree.txt +4 -0
  151. herds/web_dist/signup/__next.signup.__PAGE__.txt +9 -0
  152. herds/web_dist/signup/__next.signup.txt +5 -0
  153. herds/web_dist/signup.html +1 -0
  154. herds/web_dist/signup.txt +24 -0
  155. herds/web_dist/skill/__next._full.txt +24 -0
  156. herds/web_dist/skill/__next._head.txt +5 -0
  157. herds/web_dist/skill/__next._index.txt +8 -0
  158. herds/web_dist/skill/__next._tree.txt +4 -0
  159. herds/web_dist/skill/__next.skill.__PAGE__.txt +9 -0
  160. herds/web_dist/skill/__next.skill.txt +5 -0
  161. herds/web_dist/skill.html +1 -0
  162. herds/web_dist/skill.md +67 -0
  163. herds/web_dist/skill.txt +24 -0
  164. herds/web_dist/volume/__next._full.txt +24 -0
  165. herds/web_dist/volume/__next._head.txt +5 -0
  166. herds/web_dist/volume/__next._index.txt +8 -0
  167. herds/web_dist/volume/__next._tree.txt +4 -0
  168. herds/web_dist/volume/__next.volume.__PAGE__.txt +9 -0
  169. herds/web_dist/volume/__next.volume.txt +5 -0
  170. herds/web_dist/volume.html +1 -0
  171. herds/web_dist/volume.txt +24 -0
  172. herds/web_dist/volumes/__next._full.txt +24 -0
  173. herds/web_dist/volumes/__next._head.txt +5 -0
  174. herds/web_dist/volumes/__next._index.txt +8 -0
  175. herds/web_dist/volumes/__next._tree.txt +4 -0
  176. herds/web_dist/volumes/__next.volumes.__PAGE__.txt +9 -0
  177. herds/web_dist/volumes/__next.volumes.txt +5 -0
  178. herds/web_dist/volumes.html +1 -0
  179. herds/web_dist/volumes.txt +24 -0
  180. herds/web_dist/welcome/__next._full.txt +24 -0
  181. herds/web_dist/welcome/__next._head.txt +5 -0
  182. herds/web_dist/welcome/__next._index.txt +8 -0
  183. herds/web_dist/welcome/__next._tree.txt +4 -0
  184. herds/web_dist/welcome/__next.welcome.__PAGE__.txt +9 -0
  185. herds/web_dist/welcome/__next.welcome.txt +5 -0
  186. herds/web_dist/welcome.html +1 -0
  187. herds/web_dist/welcome.txt +24 -0
  188. herds-0.1.0.dist-info/METADATA +293 -0
  189. herds-0.1.0.dist-info/RECORD +191 -0
  190. herds-0.1.0.dist-info/WHEEL +4 -0
  191. herds-0.1.0.dist-info/entry_points.txt +3 -0
herds/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """Herds Cloud -- connect your Mac to the internet and turn it into a
2
+ programmable runtime.
3
+
4
+ import herds as dc
5
+
6
+ mac = dc.mac()
7
+ result = mac.run("xcodebuild -scheme MyApp build")
8
+ print(result.stdout)
9
+
10
+ The public surface intentionally echoes Modal so the mental model transfers:
11
+ ``App``, ``Image``, ``Volume``, ``Sandbox`` -- but the runtime is *your Mac*.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .sdk import (
17
+ App,
18
+ CommandError,
19
+ HerdsClient,
20
+ HerdsError,
21
+ Function,
22
+ Image,
23
+ Mac,
24
+ RemoteExecutionError,
25
+ Result,
26
+ Sandbox,
27
+ Secret,
28
+ Volume,
29
+ mac,
30
+ machines,
31
+ )
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "App",
37
+ "CommandError",
38
+ "HerdsClient",
39
+ "HerdsError",
40
+ "Function",
41
+ "Image",
42
+ "Mac",
43
+ "RemoteExecutionError",
44
+ "Result",
45
+ "Sandbox",
46
+ "Secret",
47
+ "Volume",
48
+ "mac",
49
+ "machines",
50
+ "__version__",
51
+ ]
herds/cli/__init__.py ADDED
@@ -0,0 +1,491 @@
1
+ """The ``herds`` CLI: connect Macs, run commands, manage volumes and images.
2
+
3
+ Mirrors the shape the product mockups call for:
4
+
5
+ herds serve # run a control plane locally (dev / self-host)
6
+ herds connect # connect THIS Mac and keep it online
7
+ herds machines # list your connected Macs
8
+ herds run -- <cmd> # run a command on a Mac
9
+ herds shell # run a one-off command / drop a quick shell
10
+ herds logs # recent jobs
11
+ herds volume ls # list volumes
12
+ herds image ls # toolchain images available on this Mac
13
+ herds install # install the launchd LaunchAgent (stay online on login)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import typer
24
+ from rich.console import Console
25
+ from rich.panel import Panel
26
+ from rich.table import Table
27
+
28
+ from .. import __version__, config
29
+ from ..daemon import machine as machine_mod
30
+
31
+ app = typer.Typer(
32
+ name="herds",
33
+ help="Connect your Mac to the internet and turn it into a programmable runtime.",
34
+ no_args_is_help=True,
35
+ add_completion=False,
36
+ )
37
+ volume_app = typer.Typer(help="Manage volumes (persistent directories on the Mac).")
38
+ image_app = typer.Typer(help="Inspect toolchain images available on this Mac.")
39
+ app.add_typer(volume_app, name="volume")
40
+ app.add_typer(image_app, name="image")
41
+
42
+ console = Console()
43
+ err = Console(stderr=True)
44
+
45
+
46
+ def _client():
47
+ from ..sdk.client import HerdsClient
48
+
49
+ return HerdsClient()
50
+
51
+
52
+ # --------------------------------------------------------------------------- #
53
+ # Top-level commands
54
+ # --------------------------------------------------------------------------- #
55
+
56
+
57
+ @app.command()
58
+ def version():
59
+ """Show the Herds version."""
60
+ console.print(f"herds {__version__}")
61
+
62
+
63
+ @app.command()
64
+ def serve(
65
+ host: str = typer.Option("127.0.0.1", help="Bind host."),
66
+ port: int = typer.Option(8787, help="Bind port."),
67
+ ):
68
+ """Run a Herds control plane locally (for development or self-hosting)."""
69
+ from ..control import serve as serve_control
70
+
71
+ console.print(
72
+ Panel.fit(
73
+ f"[bold]Herds control plane[/bold]\n\n"
74
+ f"Listening on [cyan]http://{host}:{port}[/cyan]\n"
75
+ f"Agents dial [cyan]ws://{host}:{port}/agent/ws[/cyan]\n\n"
76
+ f"Point Macs here with:\n"
77
+ f" [dim]HERDS_CONTROL_PLANE=http://{host}:{port} herds connect[/dim]",
78
+ title="serve",
79
+ border_style="green",
80
+ )
81
+ )
82
+ serve_control(host=host, port=port)
83
+
84
+
85
+ host_app = typer.Typer(help="Self-host Herds with a secure public link.", invoke_without_command=True)
86
+ app.add_typer(host_app, name="host")
87
+
88
+
89
+ @host_app.callback()
90
+ def _host_main(
91
+ ctx: typer.Context,
92
+ port: int = typer.Option(8787, help="Control plane port (auto-bumps if busy)."),
93
+ no_tunnel: bool = typer.Option(False, "--no-tunnel", help="Serve locally only, no public link."),
94
+ quick: bool = typer.Option(False, "--quick", help="Temporary Cloudflare quick tunnel (changes each run; less reliable)."),
95
+ ):
96
+ """Self-host this Mac with a permanent Tailscale Funnel link.
97
+
98
+ Run `herds host setup` first to enable Tailscale Funnel (free, permanent).
99
+ """
100
+ if ctx.invoked_subcommand is not None:
101
+ return
102
+ from ..host import run_host
103
+
104
+ run_host(port=port, tunnel=not no_tunnel, quick=quick)
105
+
106
+
107
+ @host_app.command("setup")
108
+ def _host_setup():
109
+ """Walkthrough: enable a permanent public link via Tailscale Funnel."""
110
+ from ..host import host_setup
111
+
112
+ host_setup()
113
+
114
+
115
+ @app.command()
116
+ def auth(
117
+ token: Optional[str] = typer.Option(None, "--token", help="Account token (hx_…)."),
118
+ name: Optional[str] = typer.Option(None, "--name", help="Preferred subdomain when provisioning."),
119
+ ):
120
+ """Sign in to your Herds account, so `herds host` gets you a stable link."""
121
+ from ..relay import provision_account, whoami
122
+
123
+ config.ensure_dirs()
124
+ a = config.Auth.load()
125
+
126
+ if token: # bring an existing token (e.g. to a second Mac)
127
+ info = whoami(a.relay, token)
128
+ if not info:
129
+ console.print("[red]✗ Invalid or expired token.[/red]")
130
+ raise typer.Exit(1)
131
+ a.token, a.account, a.url = token, info["account"], info.get("url")
132
+ a.save()
133
+ elif a.signed_in and not name:
134
+ console.print(f"[green]✓ Signed in[/green] as [bold]{a.account}[/bold] — run [bold]herds host[/bold].")
135
+ return
136
+ else: # provision a fresh account
137
+ info = provision_account(a.relay, name or "")
138
+ a.token, a.account, a.url = info["token"], info["account"], info.get("url")
139
+ a.save()
140
+
141
+ console.print(Panel.fit(
142
+ f"[green]✓ Signed in to Herds[/green]\n\n"
143
+ f"[bold]Account[/bold]\n {a.account}\n\n"
144
+ f"[bold]Your link[/bold] [dim](after `herds host`)[/dim]\n [cyan]{a.url or f'https://{a.account}.relay.herds.run'}[/cyan]\n\n"
145
+ f"[bold]Token[/bold] [dim](use `herds auth --token …` on your other Macs)[/dim]\n [yellow]{a.token}[/yellow]\n\n"
146
+ f"[dim]Now run [bold]herds host[/bold] — your Mac goes live at the link above.[/dim]",
147
+ title="herds auth", border_style="green",
148
+ ))
149
+
150
+
151
+ @app.command()
152
+ def relay(
153
+ port: int = typer.Option(8888, help="Relay port."),
154
+ domain: str = typer.Option("herds.run", help="Wildcard domain for host subdomains."),
155
+ ):
156
+ """Run a Herds relay server (our infra — routes you.<domain> → connected hosts)."""
157
+ from ..relay import serve_relay
158
+
159
+ console.print(f"[green]herds relay[/green] on :{port} routing [cyan]*.{domain}[/cyan] → hosts")
160
+ serve_relay(port=port, domain=domain)
161
+
162
+
163
+ @app.command()
164
+ def skill(
165
+ install: bool = typer.Option(False, "--install", help="Install to ~/.claude/skills/herds/ so Claude Code picks it up."),
166
+ dir: Optional[str] = typer.Option(None, "--dir", help="Skills directory (default: ~/.claude/skills)."),
167
+ ):
168
+ """Print the Herds agent skill (SKILL.md), or --install it for Claude Code."""
169
+ from ..skill import SKILL_MD
170
+
171
+ if not install:
172
+ print(SKILL_MD) # plain print so it's pipeable: `herds skill > SKILL.md`
173
+ return
174
+
175
+ dest = (Path(dir) if dir else Path.home() / ".claude" / "skills") / "herds" / "SKILL.md"
176
+ dest.parent.mkdir(parents=True, exist_ok=True)
177
+ dest.write_text(SKILL_MD)
178
+ console.print(Panel.fit(
179
+ f"[green]✓ Installed the Herds skill[/green]\n\n [cyan]{dest}[/cyan]\n\n"
180
+ f"[dim]Claude Code will pick it up — your agent can now drive a real Mac.[/dim]",
181
+ title="herds skill", border_style="green",
182
+ ))
183
+
184
+
185
+ @app.command()
186
+ def connect(
187
+ url: Optional[str] = typer.Argument(None, help="Host link, e.g. https://….trycloudflare.com"),
188
+ token: Optional[str] = typer.Argument(None, help="Host token from `herds host`."),
189
+ control_plane: Optional[str] = typer.Option(
190
+ None, "--control-plane", help="Control plane URL (overrides positional)."
191
+ ),
192
+ name: Optional[str] = typer.Option(None, help="Override this machine's name."),
193
+ ):
194
+ """Connect THIS Mac to a host and keep it online (runs in the foreground)."""
195
+ import asyncio
196
+
197
+ from ..daemon import Daemon
198
+
199
+ config.ensure_dirs()
200
+ cfg = config.Config.load()
201
+ target = control_plane or url
202
+ if target:
203
+ cfg.control_plane = target
204
+ if token:
205
+ creds0 = config.Credentials.load()
206
+ creds0.device_token = token
207
+ creds0.save()
208
+ if not cfg.machine_id:
209
+ cfg.machine_id = machine_mod.new_machine_id()
210
+ info = machine_mod.gather(cfg.machine_id)
211
+ cfg.machine_name = name or info.name
212
+ cfg.save()
213
+
214
+ mem = f"{info.memory_gb}GB RAM" if info.memory_gb else "RAM ?"
215
+ console.print(
216
+ Panel.fit(
217
+ f"[green]✓ Connecting[/green]\n\n"
218
+ f"[bold]Machine[/bold]\n {cfg.machine_name}\n {mem}\n macOS {info.macos_version}\n\n"
219
+ f"[bold]ID[/bold]\n {cfg.machine_id}\n\n"
220
+ f"[bold]Control plane[/bold]\n {cfg.control_plane}",
221
+ title="herds connect",
222
+ border_style="green",
223
+ )
224
+ )
225
+ console.print("[dim]Keeping this Mac online. Press Ctrl-C to disconnect.[/dim]\n")
226
+
227
+ creds = config.Credentials.load()
228
+ daemon = Daemon(cfg.control_plane, cfg.machine_id, creds.device_token)
229
+ try:
230
+ asyncio.run(daemon.run_forever())
231
+ except KeyboardInterrupt:
232
+ console.print("\n[yellow]Disconnected.[/yellow]")
233
+
234
+
235
+ @app.command()
236
+ def machines():
237
+ """List your connected Macs."""
238
+ try:
239
+ rows = _client().list_machines()
240
+ except Exception as exc: # noqa: BLE001
241
+ err.print(f"[red]Could not reach control plane:[/red] {exc}")
242
+ raise typer.Exit(1)
243
+ if not rows:
244
+ console.print("[dim]No machines yet. Run `herds connect` on a Mac.[/dim]")
245
+ return
246
+ table = Table(title="Machines", show_lines=False)
247
+ table.add_column("ID", style="cyan")
248
+ table.add_column("Name")
249
+ table.add_column("Chip", style="dim")
250
+ table.add_column("Status")
251
+ for m in rows:
252
+ info = m.get("info") or {}
253
+ status = m["status"]
254
+ color = "green" if status == "online" else "dim"
255
+ table.add_row(
256
+ m["machine_id"],
257
+ m.get("name", "?"),
258
+ info.get("chip", "—"),
259
+ f"[{color}]{status}[/{color}]",
260
+ )
261
+ console.print(table)
262
+
263
+
264
+ @app.command()
265
+ def run(
266
+ command: list[str] = typer.Argument(..., help="Command to run (after `--`)."),
267
+ machine: str = typer.Option("default", "--machine", "-m", help="Target machine id."),
268
+ image: Optional[str] = typer.Option(None, "--image", "-i", help="Image, e.g. xcode:26."),
269
+ timeout: Optional[int] = typer.Option(None, help="Timeout in seconds."),
270
+ ):
271
+ """Run a command on a Mac, streaming output live."""
272
+ from ..sdk.mac import Mac
273
+
274
+ m = Mac(machine, client=_client())
275
+ cmd = " ".join(command)
276
+ try:
277
+ result = m.run(cmd, image=image, timeout=timeout, stream=True)
278
+ except Exception as exc: # noqa: BLE001
279
+ err.print(f"[red]{exc}[/red]")
280
+ raise typer.Exit(1)
281
+ raise typer.Exit(result.exit_code)
282
+
283
+
284
+ @app.command()
285
+ def shell(
286
+ cmd: str = typer.Option(..., "--cmd", "-c", help="Command to run."),
287
+ machine: str = typer.Option("default", "--machine", "-m"),
288
+ image: Optional[str] = typer.Option(None, "--image", "-i"),
289
+ ):
290
+ """Run a one-off command on a Mac (an SSH-equivalent for quick checks)."""
291
+ from ..sdk.mac import Mac
292
+
293
+ m = Mac(machine, client=_client())
294
+ result = m.run(cmd, image=image, stream=True)
295
+ raise typer.Exit(result.exit_code)
296
+
297
+
298
+ @app.command()
299
+ def logs(machine: Optional[str] = typer.Option(None, "--machine", "-m")):
300
+ """Show recent jobs."""
301
+ import httpx
302
+
303
+ cfg = config.Config.load()
304
+ try:
305
+ params = {"machine_id": machine} if machine else {}
306
+ r = httpx.get(f"{cfg.control_plane}/v1/jobs", params=params, timeout=10)
307
+ jobs = r.json()["jobs"]
308
+ except Exception as exc: # noqa: BLE001
309
+ err.print(f"[red]Could not reach control plane:[/red] {exc}")
310
+ raise typer.Exit(1)
311
+ if not jobs:
312
+ console.print("[dim]No jobs yet.[/dim]")
313
+ return
314
+ table = Table(title="Recent jobs")
315
+ table.add_column("Request", style="cyan")
316
+ table.add_column("Machine", style="dim")
317
+ table.add_column("Command")
318
+ table.add_column("State")
319
+ table.add_column("Exit", justify="right")
320
+ for j in jobs:
321
+ st = j["state"]
322
+ color = {"succeeded": "green", "failed": "red"}.get(st, "yellow")
323
+ table.add_row(
324
+ j["request_id"], j["machine_id"], (j.get("command") or "")[:40],
325
+ f"[{color}]{st}[/{color}]",
326
+ "" if j.get("exit_code") is None else str(j["exit_code"]),
327
+ )
328
+ console.print(table)
329
+
330
+
331
+ @app.command()
332
+ def status():
333
+ """Show local Herds configuration."""
334
+ cfg = config.Config.load()
335
+ creds = config.Credentials.load()
336
+ table = Table(show_header=False)
337
+ table.add_column(style="bold")
338
+ table.add_column()
339
+ table.add_row("Herds home", str(config.HERDS_HOME))
340
+ table.add_row("Control plane", cfg.control_plane)
341
+ table.add_row("This machine", f"{cfg.machine_name or '—'} ({cfg.machine_id or 'not connected'})")
342
+ table.add_row("API key", "set" if creds.api_key else "[dim]none[/dim]")
343
+ table.add_row("Device token", "set" if creds.device_token else "[dim]none[/dim]")
344
+ console.print(table)
345
+
346
+
347
+ # --------------------------------------------------------------------------- #
348
+ # volume subcommands
349
+ # --------------------------------------------------------------------------- #
350
+
351
+
352
+ @volume_app.command("ls")
353
+ def volume_ls():
354
+ """List volumes on this Mac."""
355
+ config.ensure_dirs()
356
+ vols = sorted(p for p in config.VOLUMES_DIR.iterdir() if p.is_dir()) if config.VOLUMES_DIR.exists() else []
357
+ if not vols:
358
+ console.print("[dim]No volumes yet.[/dim]")
359
+ return
360
+ table = Table(title="Volumes")
361
+ table.add_column("Name", style="cyan")
362
+ table.add_column("Path", style="dim")
363
+ table.add_column("Size", justify="right")
364
+ for v in vols:
365
+ size = sum(f.stat().st_size for f in v.rglob("*") if f.is_file())
366
+ table.add_row(v.name, str(v), _human(size))
367
+ console.print(table)
368
+
369
+
370
+ @volume_app.command("create")
371
+ def volume_create(name: str):
372
+ """Create a volume (a persistent directory)."""
373
+ config.ensure_dirs()
374
+ (config.VOLUMES_DIR / name).mkdir(parents=True, exist_ok=True)
375
+ console.print(f"[green]✓[/green] created volume [cyan]{name}[/cyan]")
376
+
377
+
378
+ @volume_app.command("rm")
379
+ def volume_rm(name: str, yes: bool = typer.Option(False, "--yes", "-y")):
380
+ """Delete a volume."""
381
+ path = config.VOLUMES_DIR / name
382
+ if not path.exists():
383
+ err.print(f"[red]No such volume:[/red] {name}")
384
+ raise typer.Exit(1)
385
+ if not yes:
386
+ typer.confirm(f"Delete volume {name} and all its data?", abort=True)
387
+ shutil.rmtree(path)
388
+ console.print(f"[green]✓[/green] deleted volume [cyan]{name}[/cyan]")
389
+
390
+
391
+ # --------------------------------------------------------------------------- #
392
+ # image subcommands
393
+ # --------------------------------------------------------------------------- #
394
+
395
+
396
+ @image_app.command("ls")
397
+ def image_ls():
398
+ """Show toolchain images and what's actually installed on this Mac."""
399
+ table = Table(title="Images")
400
+ table.add_column("Image", style="cyan")
401
+ table.add_column("Backed by", style="dim")
402
+ table.add_column("Available")
403
+
404
+ xcodes = list(Path("/Applications").glob("Xcode*.app"))
405
+ table.add_row(
406
+ "xcode:<version>",
407
+ "DEVELOPER_DIR / xcodes",
408
+ f"[green]{len(xcodes)} installed[/green]" if xcodes else "[yellow]none[/yellow]",
409
+ )
410
+ has_mise = shutil.which("mise") is not None
411
+ for kind in ("node", "python", "ruby", "go"):
412
+ table.add_row(
413
+ f"{kind}:<version>",
414
+ "mise",
415
+ "[green]mise ready[/green]" if has_mise else "[yellow]install mise[/yellow]",
416
+ )
417
+ table.add_row("macos", "host environment", "[green]always[/green]")
418
+ console.print(table)
419
+
420
+
421
+ # --------------------------------------------------------------------------- #
422
+ # launchd install / uninstall
423
+ # --------------------------------------------------------------------------- #
424
+
425
+ _PLIST_LABEL = "ai.spawnlabs.herds"
426
+ _PLIST_PATH = Path.home() / "Library/LaunchAgents" / f"{_PLIST_LABEL}.plist"
427
+
428
+
429
+ def _plist_contents(herds_bin: str) -> str:
430
+ config.ensure_dirs()
431
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
432
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
433
+ <plist version="1.0">
434
+ <dict>
435
+ <key>Label</key><string>{_PLIST_LABEL}</string>
436
+ <key>ProgramArguments</key>
437
+ <array>
438
+ <string>{herds_bin}</string>
439
+ <string>connect</string>
440
+ </array>
441
+ <key>RunAtLoad</key><true/>
442
+ <key>KeepAlive</key>
443
+ <dict><key>NetworkState</key><true/><key>SuccessfulExit</key><false/></dict>
444
+ <key>ThrottleInterval</key><integer>10</integer>
445
+ <key>StandardOutPath</key><string>{config.LOGS_DIR / 'daemon.out.log'}</string>
446
+ <key>StandardErrorPath</key><string>{config.LOGS_DIR / 'daemon.err.log'}</string>
447
+ </dict>
448
+ </plist>
449
+ """
450
+
451
+
452
+ @app.command()
453
+ def install():
454
+ """Install a launchd LaunchAgent so this Mac stays online across logins."""
455
+ herds_bin = shutil.which("herds") or "herds"
456
+ _PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
457
+ _PLIST_PATH.write_text(_plist_contents(herds_bin))
458
+ uid = subprocess.run(["id", "-u"], capture_output=True, text=True).stdout.strip()
459
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(_PLIST_PATH)],
460
+ capture_output=True)
461
+ res = subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(_PLIST_PATH)],
462
+ capture_output=True, text=True)
463
+ if res.returncode == 0:
464
+ console.print(f"[green]✓[/green] installed LaunchAgent at [dim]{_PLIST_PATH}[/dim]")
465
+ console.print("This Mac will reconnect automatically on login and after crashes.")
466
+ else:
467
+ err.print(f"[red]launchctl bootstrap failed:[/red] {res.stderr.strip()}")
468
+ raise typer.Exit(1)
469
+
470
+
471
+ @app.command()
472
+ def uninstall():
473
+ """Remove the launchd LaunchAgent."""
474
+ uid = subprocess.run(["id", "-u"], capture_output=True, text=True).stdout.strip()
475
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(_PLIST_PATH)],
476
+ capture_output=True)
477
+ if _PLIST_PATH.exists():
478
+ _PLIST_PATH.unlink()
479
+ console.print("[green]✓[/green] removed LaunchAgent")
480
+
481
+
482
+ def _human(n: int) -> str:
483
+ for unit in ("B", "KB", "MB", "GB"):
484
+ if n < 1024:
485
+ return f"{n:.0f}{unit}"
486
+ n /= 1024
487
+ return f"{n:.1f}TB"
488
+
489
+
490
+ if __name__ == "__main__":
491
+ app()