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.
- herds/__init__.py +51 -0
- herds/cli/__init__.py +491 -0
- herds/config.py +170 -0
- herds/control/__init__.py +810 -0
- herds/control/store.py +567 -0
- herds/daemon/__init__.py +258 -0
- herds/daemon/__main__.py +6 -0
- herds/daemon/executor.py +330 -0
- herds/daemon/files.py +79 -0
- herds/daemon/images.py +106 -0
- herds/daemon/machine.py +69 -0
- herds/daemon/metrics.py +57 -0
- herds/host.py +369 -0
- herds/protocol.py +259 -0
- herds/relay.py +633 -0
- herds/sdk/__init__.py +28 -0
- herds/sdk/app.py +155 -0
- herds/sdk/client.py +179 -0
- herds/sdk/image.py +62 -0
- herds/sdk/mac.py +171 -0
- herds/sdk/sandbox.py +163 -0
- herds/sdk/secret.py +46 -0
- herds/sdk/volume.py +31 -0
- herds/skill.py +74 -0
- herds/web_dist/404.html +1 -0
- herds/web_dist/__next.__PAGE__.txt +9 -0
- herds/web_dist/__next._full.txt +22 -0
- herds/web_dist/__next._head.txt +5 -0
- herds/web_dist/__next._index.txt +8 -0
- herds/web_dist/__next._tree.txt +4 -0
- herds/web_dist/_next/static/chunks/02zcztjkk52mq.js +1 -0
- herds/web_dist/_next/static/chunks/05e83anyrmnuv.js +1 -0
- herds/web_dist/_next/static/chunks/0755w0rpp670_.js +1 -0
- herds/web_dist/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- herds/web_dist/_next/static/chunks/0i36h7rkvi9ay.js +1 -0
- herds/web_dist/_next/static/chunks/0iyrc8fu-0ayb.js +9 -0
- herds/web_dist/_next/static/chunks/0pq0d6vtp3kvw.js +1 -0
- herds/web_dist/_next/static/chunks/0tumh1559hcih.js +1 -0
- herds/web_dist/_next/static/chunks/120ol6mue7vp2.js +1 -0
- herds/web_dist/_next/static/chunks/1gn-yoma2aujf.js +5 -0
- herds/web_dist/_next/static/chunks/1op8bvg-0q6tl.js +1 -0
- herds/web_dist/_next/static/chunks/1pn5tud4t835x.js +9 -0
- herds/web_dist/_next/static/chunks/1sfqbf5qbd8y-.js +0 -0
- herds/web_dist/_next/static/chunks/248b_461ikafx.js +1 -0
- herds/web_dist/_next/static/chunks/2c-aykqsty-dq.js +1 -0
- herds/web_dist/_next/static/chunks/2j4_bxbfhamp6.js +1 -0
- herds/web_dist/_next/static/chunks/2sp6kvnbpdi-p.js +31 -0
- herds/web_dist/_next/static/chunks/2vmk9ipzrtj92.js +4 -0
- herds/web_dist/_next/static/chunks/37gl9pjlkkam1.js +1 -0
- herds/web_dist/_next/static/chunks/3aspsawddjes8.js +2 -0
- herds/web_dist/_next/static/chunks/3iww1zdedzdwz.js +1 -0
- herds/web_dist/_next/static/chunks/3j_4r2-hqycta.js +1 -0
- herds/web_dist/_next/static/chunks/3oi-7u0vpjh9h.js +1 -0
- herds/web_dist/_next/static/chunks/3sqbodmsyaqkm.js +1 -0
- herds/web_dist/_next/static/chunks/3v-rqdys4eu0a.js +0 -0
- herds/web_dist/_next/static/chunks/413vhtqaffkli.css +3 -0
- herds/web_dist/_next/static/chunks/415in7dhfqo3x.js +1 -0
- herds/web_dist/_next/static/chunks/44nsp8_bkorwp.js +0 -0
- herds/web_dist/_next/static/chunks/turbopack-0oar_l0ke-f9y.js +1 -0
- herds/web_dist/_next/static/media/GeistMono_Variable.p.3ms9vq719j3f8.woff2 +0 -0
- herds/web_dist/_next/static/media/Geist_Variable-s.p.0mrjj4bg00-he.woff2 +0 -0
- herds/web_dist/_next/static/vje3Zo17YIetwDl0k5oV4/_buildManifest.js +11 -0
- herds/web_dist/_next/static/vje3Zo17YIetwDl0k5oV4/_clientMiddlewareManifest.js +1 -0
- herds/web_dist/_next/static/vje3Zo17YIetwDl0k5oV4/_ssgManifest.js +1 -0
- herds/web_dist/_not-found/__next._full.txt +18 -0
- herds/web_dist/_not-found/__next._head.txt +5 -0
- herds/web_dist/_not-found/__next._index.txt +8 -0
- herds/web_dist/_not-found/__next._not-found.__PAGE__.txt +5 -0
- herds/web_dist/_not-found/__next._not-found.txt +5 -0
- herds/web_dist/_not-found/__next._tree.txt +2 -0
- herds/web_dist/_not-found.html +1 -0
- herds/web_dist/_not-found.txt +18 -0
- herds/web_dist/dashboard/__next._full.txt +24 -0
- herds/web_dist/dashboard/__next._head.txt +5 -0
- herds/web_dist/dashboard/__next._index.txt +8 -0
- herds/web_dist/dashboard/__next._tree.txt +4 -0
- herds/web_dist/dashboard/__next.dashboard.__PAGE__.txt +9 -0
- herds/web_dist/dashboard/__next.dashboard.txt +5 -0
- herds/web_dist/dashboard.html +1 -0
- herds/web_dist/dashboard.txt +24 -0
- herds/web_dist/index.html +9 -0
- herds/web_dist/index.txt +22 -0
- herds/web_dist/login/__next._full.txt +24 -0
- herds/web_dist/login/__next._head.txt +5 -0
- herds/web_dist/login/__next._index.txt +8 -0
- herds/web_dist/login/__next._tree.txt +4 -0
- herds/web_dist/login/__next.login.__PAGE__.txt +9 -0
- herds/web_dist/login/__next.login.txt +5 -0
- herds/web_dist/login.html +1 -0
- herds/web_dist/login.txt +24 -0
- herds/web_dist/machine/__next._full.txt +24 -0
- herds/web_dist/machine/__next._head.txt +5 -0
- herds/web_dist/machine/__next._index.txt +8 -0
- herds/web_dist/machine/__next._tree.txt +4 -0
- herds/web_dist/machine/__next.machine.__PAGE__.txt +9 -0
- herds/web_dist/machine/__next.machine.txt +5 -0
- herds/web_dist/machine.html +1 -0
- herds/web_dist/machine.txt +24 -0
- herds/web_dist/machines/__next._full.txt +24 -0
- herds/web_dist/machines/__next._head.txt +5 -0
- herds/web_dist/machines/__next._index.txt +8 -0
- herds/web_dist/machines/__next._tree.txt +4 -0
- herds/web_dist/machines/__next.machines.__PAGE__.txt +9 -0
- herds/web_dist/machines/__next.machines.txt +5 -0
- herds/web_dist/machines.html +1 -0
- herds/web_dist/machines.txt +24 -0
- herds/web_dist/runs/__next._full.txt +24 -0
- herds/web_dist/runs/__next._head.txt +5 -0
- herds/web_dist/runs/__next._index.txt +8 -0
- herds/web_dist/runs/__next._tree.txt +4 -0
- herds/web_dist/runs/__next.runs.__PAGE__.txt +9 -0
- herds/web_dist/runs/__next.runs.txt +5 -0
- herds/web_dist/runs.html +1 -0
- herds/web_dist/runs.txt +24 -0
- herds/web_dist/sandbox/__next._full.txt +24 -0
- herds/web_dist/sandbox/__next._head.txt +5 -0
- herds/web_dist/sandbox/__next._index.txt +8 -0
- herds/web_dist/sandbox/__next._tree.txt +4 -0
- herds/web_dist/sandbox/__next.sandbox.__PAGE__.txt +9 -0
- herds/web_dist/sandbox/__next.sandbox.txt +5 -0
- herds/web_dist/sandbox.html +1 -0
- herds/web_dist/sandbox.txt +24 -0
- herds/web_dist/sandboxes/__next._full.txt +24 -0
- herds/web_dist/sandboxes/__next._head.txt +5 -0
- herds/web_dist/sandboxes/__next._index.txt +8 -0
- herds/web_dist/sandboxes/__next._tree.txt +4 -0
- herds/web_dist/sandboxes/__next.sandboxes.__PAGE__.txt +9 -0
- herds/web_dist/sandboxes/__next.sandboxes.txt +5 -0
- herds/web_dist/sandboxes.html +1 -0
- herds/web_dist/sandboxes.txt +24 -0
- herds/web_dist/secrets/__next._full.txt +24 -0
- herds/web_dist/secrets/__next._head.txt +5 -0
- herds/web_dist/secrets/__next._index.txt +8 -0
- herds/web_dist/secrets/__next._tree.txt +4 -0
- herds/web_dist/secrets/__next.secrets.__PAGE__.txt +9 -0
- herds/web_dist/secrets/__next.secrets.txt +5 -0
- herds/web_dist/secrets.html +1 -0
- herds/web_dist/secrets.txt +24 -0
- herds/web_dist/settings/__next._full.txt +24 -0
- herds/web_dist/settings/__next._head.txt +5 -0
- herds/web_dist/settings/__next._index.txt +8 -0
- herds/web_dist/settings/__next._tree.txt +4 -0
- herds/web_dist/settings/__next.settings.__PAGE__.txt +9 -0
- herds/web_dist/settings/__next.settings.txt +5 -0
- herds/web_dist/settings.html +5 -0
- herds/web_dist/settings.txt +24 -0
- herds/web_dist/signup/__next._full.txt +24 -0
- herds/web_dist/signup/__next._head.txt +5 -0
- herds/web_dist/signup/__next._index.txt +8 -0
- herds/web_dist/signup/__next._tree.txt +4 -0
- herds/web_dist/signup/__next.signup.__PAGE__.txt +9 -0
- herds/web_dist/signup/__next.signup.txt +5 -0
- herds/web_dist/signup.html +1 -0
- herds/web_dist/signup.txt +24 -0
- herds/web_dist/skill/__next._full.txt +24 -0
- herds/web_dist/skill/__next._head.txt +5 -0
- herds/web_dist/skill/__next._index.txt +8 -0
- herds/web_dist/skill/__next._tree.txt +4 -0
- herds/web_dist/skill/__next.skill.__PAGE__.txt +9 -0
- herds/web_dist/skill/__next.skill.txt +5 -0
- herds/web_dist/skill.html +1 -0
- herds/web_dist/skill.md +67 -0
- herds/web_dist/skill.txt +24 -0
- herds/web_dist/volume/__next._full.txt +24 -0
- herds/web_dist/volume/__next._head.txt +5 -0
- herds/web_dist/volume/__next._index.txt +8 -0
- herds/web_dist/volume/__next._tree.txt +4 -0
- herds/web_dist/volume/__next.volume.__PAGE__.txt +9 -0
- herds/web_dist/volume/__next.volume.txt +5 -0
- herds/web_dist/volume.html +1 -0
- herds/web_dist/volume.txt +24 -0
- herds/web_dist/volumes/__next._full.txt +24 -0
- herds/web_dist/volumes/__next._head.txt +5 -0
- herds/web_dist/volumes/__next._index.txt +8 -0
- herds/web_dist/volumes/__next._tree.txt +4 -0
- herds/web_dist/volumes/__next.volumes.__PAGE__.txt +9 -0
- herds/web_dist/volumes/__next.volumes.txt +5 -0
- herds/web_dist/volumes.html +1 -0
- herds/web_dist/volumes.txt +24 -0
- herds/web_dist/welcome/__next._full.txt +24 -0
- herds/web_dist/welcome/__next._head.txt +5 -0
- herds/web_dist/welcome/__next._index.txt +8 -0
- herds/web_dist/welcome/__next._tree.txt +4 -0
- herds/web_dist/welcome/__next.welcome.__PAGE__.txt +9 -0
- herds/web_dist/welcome/__next.welcome.txt +5 -0
- herds/web_dist/welcome.html +1 -0
- herds/web_dist/welcome.txt +24 -0
- herds-0.1.0.dist-info/METADATA +293 -0
- herds-0.1.0.dist-info/RECORD +191 -0
- herds-0.1.0.dist-info/WHEEL +4 -0
- 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()
|