hyperhive/hivectl/src/cli.rs

641 lines
24 KiB
Rust

//! hivectl clap command tree: the `Cli` root, the top-level `Cmd`
//! verb enum, and every subcommand enum + shared args struct.
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "hivectl",
about = "hyperhive host CLI — operator-facing administration",
long_about = "\
Sibling to the `hive-c0re` daemon binary. Covers host-side admin \
operations that don't go through the broker — manual user \
provisioning on the bundled forge + matrix containers, plus future \
recovery / debugging verbs.\
"
)]
pub struct Cli {
/// Path to the hive-c0re host admin socket, used by the daemon-assisted
/// verbs (`agent`, `list-agents`, `stop`, `start`). Global: accepted
/// before or after the subcommand. Verbs that don't talk to the daemon
/// ignore it.
#[arg(long, global = true, default_value = DEFAULT_HOST_SOCKET)]
pub(crate) socket: PathBuf,
#[command(subcommand)]
pub(crate) cmd: Cmd,
}
#[derive(Subcommand)]
pub enum Cmd {
/// Forgejo user provisioning.
///
/// Manual entry point to the same idempotent provisioning c0re runs at
/// boot — for recovery, ad-hoc reprovisioning, or fixing one agent
/// without bouncing the daemon.
Forge {
#[command(subcommand)]
cmd: ForgeCmd,
},
/// matrix-tuwunel user provisioning.
///
/// Manual entry point to the same idempotent provisioning c0re runs at
/// boot — for re-registering an agent the boot sweep skipped, or after
/// wiping a token file.
Matrix {
#[command(subcommand)]
cmd: MatrixCmd,
},
/// GitHub account provisioning.
///
/// Store an operator-supplied personal access token (PAT) for an agent
/// so its `gh` and git can authenticate. No account is created — the
/// PAT is for an existing GitHub account.
Github {
#[command(subcommand)]
cmd: GithubCmd,
},
/// Gateway htpasswd user management.
///
/// Add, remove, or list users for the gateway's HTTP Basic auth.
Gateway {
#[command(subcommand)]
cmd: GatewayCmd,
},
/// Lifecycle actions on ONE managed agent container. Needs the
/// hive-c0re daemon running.
///
/// Everything here targets a single named agent (`hivectl agent foo
/// restart`, `hivectl agent foo choom`, …) — anything that acts
/// hive-wide lives at the top level instead (`list-agents`,
/// `restart`/`stop`/`start` with a scope, `quota-enable`).
Agent {
/// Agent name (e.g. `damocles`, `iris`).
name: String,
#[command(subcommand)]
cmd: AgentCmd,
},
/// Show all managed agents with their status and technical state.
///
/// Global — not scoped to one agent, so it lives at the top level
/// rather than under `hivectl agent <name>`. Needs the hive-c0re
/// daemon running.
ListAgents {
/// Emit the raw JSON rows instead of the padded table (for
/// scripting). The table is the default human-readable shape.
#[arg(long)]
json: bool,
},
/// Enable btrfs qgroup accounting on the agent-state filesystem.
///
/// Global one-shot toggle (not per-agent), hence top-level rather than
/// under `hivectl agent <name>`. Idempotent — safe to re-run. Once
/// enabled, `hivectl agent <name> quota show`/`quota set` work.
QuotaEnable,
/// Operator approval queue: list, approve, or deny pending requests.
///
/// Needs the hive-c0re daemon running.
Approvals {
#[command(subcommand)]
cmd: ApprovalsCmd,
},
/// WireGuard inter-hive mesh setup helpers.
///
/// Generate this hive's mesh key and print the nix to enable the mesh,
/// add a peer, or inspect live interface state.
Wg {
#[command(subcommand)]
cmd: WgCmd,
},
/// Generate the federation peer-config block for THIS hive.
///
/// Prints the nix a peer operator pastes into their swarm config to
/// trust and reach this hive.
PeerConfig {
/// This hive's WireGuard mesh address (e.g. `10.42.0.1/32`),
/// emitted as `wireguardAddress`. Omit when not running the mesh.
#[arg(long)]
wg_address: Option<String>,
/// This hive's public WireGuard endpoint (`host:port`), emitted as
/// `wireguardEndpoint`. Omit when peers dial in / no mesh.
#[arg(long)]
wg_endpoint: Option<String>,
},
/// Stop containers hive-wide in one operator action.
///
/// Bare `hivectl stop` stops everything; scope flags narrow it to
/// specific sub-agents or infra containers.
Stop {
#[command(flatten)]
scope: ScopeArgs,
/// Gracefully quiesce each agent before stopping, instead of a
/// hard stop. Each agent gets a graceful-stop DAG on the job
/// queue: the harness is signalled, runs one stop-checkpoint
/// turn to flush durable `/state`, drains, then the container
/// is stopped (bounded by a 3-min timeout that falls back to a
/// hard stop). All drains overlap. Applies to agents only.
#[arg(long)]
graceful: bool,
/// Return immediately after the stop DAGs are queued instead of
/// waiting for them with live per-node progress.
#[arg(long)]
no_wait: bool,
},
/// Start containers hive-wide — the inverse of `hivectl stop`.
///
/// Bare `hivectl start` restores the agents stopped by the last
/// broad-scope `stop` (or starts everything if none); scope flags
/// narrow it.
Start {
#[command(flatten)]
scope: ScopeArgs,
/// Return immediately after the start DAGs are queued instead
/// of waiting for them with live per-node progress.
#[arg(long)]
no_wait: bool,
},
/// Restart containers hive-wide — `stop` then `start` over one scope.
///
/// Bare `hivectl restart` restarts everything; scope flags narrow it.
Restart {
#[command(flatten)]
scope: ScopeArgs,
/// Gracefully quiesce each agent on the stop half (see
/// `stop --graceful`). Applies to agents only.
#[arg(long)]
graceful: bool,
},
/// Print (and best-effort open in a browser) a hive web surface URL.
///
/// Resolves the URL from the running daemon so custom forge / matrix
/// domains work. Bare `hivectl open` opens the operator dashboard.
Open {
/// Which surface to open. Defaults to the operator dashboard.
#[arg(value_enum, default_value_t = OpenTarget::Home)]
target: OpenTarget,
},
/// Emit the full CLI reference as `CommonMark` to stdout.
///
/// Hidden tooling command used by the docs build to keep the published
/// `hivectl` reference in lockstep with the code.
#[command(hide = true)]
MarkdownDocs,
/// Generate a shell completion script for `hivectl` and print it to
/// stdout.
///
/// Supports bash, zsh, fish, elvish, and powershell. The NixOS module
/// already installs the zsh script system-wide; this is for ad-hoc or
/// other-shell use.
Completions {
/// Shell to emit completions for.
shell: clap_complete::Shell,
},
}
/// Which hive web surface `hivectl open` targets.
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
pub enum OpenTarget {
/// The operator dashboard (`https://<domain>/`).
Home,
/// The forge (Forgejo) web UI.
Forge,
/// The matrix GUI (fluffychat).
Matrix,
}
/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag
/// set the verb targets **everything** (all sub-agents + every controllable
/// infra container). Setting any flag restricts to the selected classes,
/// additively.
// One bool per selectable container class, each mapping 1:1 to a clap flag;
// orthogonal toggles, not a state machine — hence the bools allow (mirrors
// `hive_host_sock::LifecycleScope`).
#[allow(clippy::struct_excessive_bools)]
#[derive(Args)]
pub struct ScopeArgs {
/// All sub-agent containers.
#[arg(long)]
agents: bool,
/// A specific sub-agent by name. Repeatable: `--agent a --agent b`.
#[arg(long = "agent", value_name = "NAME")]
agent: Vec<String>,
/// The CI runner container (`hive-ci`).
#[arg(long)]
ci: bool,
/// The forge container (`hive-forge`).
#[arg(long)]
forge: bool,
/// The gateway container (`hive-gateway`).
#[arg(long)]
gateway: bool,
/// The matrix container (`hive-matrix`).
#[arg(long)]
matrix: bool,
}
impl ScopeArgs {
pub(crate) fn to_scope(&self) -> hive_host_sock::LifecycleScope {
hive_host_sock::LifecycleScope {
agents: self.agents,
agent_names: self.agent.clone(),
ci: self.ci,
forge: self.forge,
gateway: self.gateway,
matrix: self.matrix,
}
}
}
#[derive(Subcommand)]
pub enum ForgeCmd {
/// Create or refresh the Forgejo account + token for `<name>`.
///
/// For an existing agent, persists the token to its state dir; for a
/// human/other account, prints the token to stdout. Set a password to
/// enable forge web-UI login (a random throwaway is used otherwise).
CreateUser {
/// Forgejo username. For agents: the container/agent name
/// (`<n>` in `h-<n>`; manager uses the literal `manager`).
/// For humans: any forgejo username — `mara`, `damocles`, etc.
name: String,
/// Set the account password to this string instead of a random
/// throwaway. Use this for operator accounts that need to log
/// into the forge web UI. Mutually exclusive with
/// `--password-stdin`. WARNING: the password is visible in
/// shell history + process listings; prefer `--password-stdin`
/// for anything sensitive.
#[arg(long)]
password: Option<String>,
/// Read the password from stdin (single line, trailing newline
/// stripped) instead of an inline flag. Mutually exclusive with
/// `--password`.
#[arg(long, conflicts_with = "password")]
password_stdin: bool,
},
/// Show + reconcile the divergence between an agent's local applied
/// config checkout and its forge `agent-configs/<agent>` main.
///
/// Always prints the diff first. `--from forge` resets the local
/// checkout to forge main (effective on the next deploy); `--from local`
/// is not supported yet. With no `--from`, prompts for the direction.
ReconcileConfig {
/// Agent whose config branches to reconcile.
agent: String,
/// Which side to reconcile from. Omit to be prompted after the diff.
#[arg(long, value_enum)]
from: Option<ReconcileFrom>,
/// Include the full diff (not just `--stat`) in the report.
#[arg(long)]
verbose: bool,
},
}
/// Which side to reconcile config branches from (`hivectl forge
/// reconcile-config --from`). Maps to [`hive_host_sock::ReconcileDirection`].
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum ReconcileFrom {
/// Reset the local applied checkout to forge main.
Forge,
/// Advance forge main from local — not supported yet.
Local,
}
impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
fn from(from: ReconcileFrom) -> Self {
match from {
ReconcileFrom::Forge => Self::Forge,
ReconcileFrom::Local => Self::Local,
}
}
}
#[derive(Subcommand)]
pub enum MatrixCmd {
/// Create or refresh the matrix account + access token for `<name>`.
///
/// For an existing agent, persists the token to its state dir; for a
/// human/other account, prints the access token to stdout. Set a
/// password to enable matrix web-client login (a random throwaway is
/// used otherwise).
CreateUser {
/// Matrix localpart. For agents: the container/agent name.
/// For humans: any matrix localpart — `mara`, `damocles`, etc.
name: String,
/// Set the account password to this string instead of a random
/// throwaway. Use this for operator accounts that need to log
/// into matrix web clients via `m.login.password`. Mutually
/// exclusive with `--password-stdin`. WARNING: the
/// password is visible in shell history + process listings;
/// prefer `--password-stdin` for anything sensitive.
#[arg(long)]
password: Option<String>,
/// Read the password from stdin (single line, trailing newline
/// stripped) instead of an inline flag. Mutually exclusive with
/// `--password`.
#[arg(long, conflicts_with = "password")]
password_stdin: bool,
},
/// Provision (or re-provision) the hive system admin matrix account.
///
/// Runs automatically on startup; run manually to recover a missing
/// admin token.
SyncAdmin,
/// Promote a matrix user to homeserver admin.
PromoteUser {
/// Matrix localpart of the user to promote (e.g. `argus`).
name: String,
},
/// Reset a matrix user's password via the admin API.
///
/// Persists the new password so a later `create-user` can re-login.
ResetPassword {
/// Matrix localpart of the account to reset (e.g. `argus`).
name: String,
},
/// Invite a matrix user to the hive Space, or a specific room with
/// `--room`. Idempotent.
Invite {
/// User to invite: a full id (`@mara:server`) or a bare
/// localpart (qualified with the homeserver's `server_name`).
user: String,
/// Target room id (`!abc:server`) or alias (`#name:server`).
/// Omit to invite to the hive Space.
#[arg(long)]
room: Option<String>,
},
}
#[derive(Subcommand)]
pub enum GithubCmd {
/// Store a GitHub PAT for `<agent>` so its `gh` and git can
/// authenticate.
///
/// Prefer `--token-stdin` — an inline `--token` is visible in shell
/// history.
SetToken {
/// Logical agent name (the container/agent name).
agent: String,
/// The PAT value inline. Mutually exclusive with `--token-stdin`.
#[arg(long)]
token: Option<String>,
/// Read the PAT from stdin (trailing newline stripped). Mutually
/// exclusive with `--token`.
#[arg(long, conflicts_with = "token")]
token_stdin: bool,
},
}
#[derive(Subcommand)]
pub enum GatewayCmd {
/// Add a user or update an existing user's password in the gateway
/// htpasswd.
///
/// Use `--password-stdin` to keep the password out of shell history.
CreateUser {
/// Username to add or update.
username: String,
/// Set the password inline. WARNING: visible in shell history and
/// process listings — prefer `--password-stdin` for sensitive input.
/// Mutually exclusive with `--password-stdin`.
#[arg(long, conflicts_with = "password_stdin")]
password: Option<String>,
/// Read the password from stdin (single line, trailing newline
/// stripped). Mutually exclusive with `--password`.
#[arg(long)]
password_stdin: bool,
},
/// Remove a user from the gateway htpasswd.
DeleteUser {
/// Username to remove.
username: String,
},
/// List all gateway htpasswd usernames, one per line.
ListUsers,
}
#[derive(Subcommand)]
pub enum WgCmd {
/// Generate this hive's WireGuard key (if absent) and print its public
/// key plus the nix to enable the mesh.
Init {
/// This hive's mesh address (e.g. `10.42.0.1/32`) to bake into the
/// printed snippet. Omit to get a placeholder you fill in.
#[arg(long)]
address: Option<String>,
},
/// Print the nix to add a peer hive to the mesh.
Peer {
/// Peer hive's DNS domain (the `swarm.peers` attrset key).
domain: String,
/// Peer's WireGuard public key (from its `hivectl wg init`).
#[arg(long)]
pubkey: String,
/// Peer's mesh address (e.g. `10.42.0.2/32`).
#[arg(long)]
address: String,
/// Peer's `host:port` endpoint (omit for a peer that only dials out,
/// e.g. one behind NAT — it must set an endpoint pointing back here).
#[arg(long)]
endpoint: Option<String>,
},
/// Show the live mesh interface state.
Status,
}
// Default host admin socket path. Shared with `hive-c0re`'s `main.rs`
// default via `hive_host_sock::HOST_SOCKET` — the daemon binds there
// and `hivectl` connects to it.
pub(crate) use hive_host_sock::HOST_SOCKET as DEFAULT_HOST_SOCKET;
/// Verbs under `hivectl agent <name> <verb>` — every one of these targets
/// the single agent named on the parent command, so none of them carry
/// their own `name` field.
#[derive(Subcommand)]
pub enum AgentCmd {
/// Stop and start this agent container without rebuilding config.
Restart {
/// Return immediately after the restart DAG is queued.
#[arg(long)]
no_wait: bool,
},
/// Park this agent's turn loop, leaving the container running.
///
/// The harness stops driving turns but keeps serving its web UI and
/// MCP daemons, so the container, its mounts and its warm caches stay
/// up while it burns no tokens. Inbox messages queue unacked and the
/// backlog drains on `resume`. Sticky: it survives a restart, and
/// pausing a stopped agent makes it come up paused.
Pause,
/// Resume this paused agent — it drains whatever queued up while parked.
Resume,
/// Spawn this agent container directly, bypassing the approval queue.
///
/// Operator-on-the-host only; use `request-spawn` for an approval-gated
/// spawn.
Spawn,
/// Queue a spawn request for operator approval.
RequestSpawn,
/// Stop this managed container (graceful).
Kill,
/// Tear down this sub-agent container, keeping its state by default.
/// No undo.
Destroy {
/// Also wipe the agent's state dirs (config + creds + notes).
#[arg(long)]
purge: bool,
},
/// Apply pending config to this managed container.
Rebuild,
/// Move this agent in the topology tree — under a new parent, or to
/// root.
SetParent {
/// New parent agent name. Mutually exclusive with `--root`.
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
parent: Option<String>,
/// Promote this agent to root (no parent).
#[arg(long)]
root: bool,
},
/// Declare this agent's CPU/memory limits, overriding the hive-wide
/// defaults.
///
/// Replaces the agent's whole override entry rather than merging into
/// it: any limit you don't pass returns to the hive-wide default. To
/// change one and keep the other, pass both. Disk is a separate
/// resource with its own group — see `quota`.
SetLimits {
/// systemd `CPUQuota=` value, e.g. `400%` (100% = one full core).
#[arg(long, conflicts_with = "reset")]
cpu_quota: Option<String>,
/// systemd `MemoryMax=` value, e.g. `8G`, `50%`, or `infinity`.
#[arg(long, conflicts_with = "reset")]
memory_max: Option<String>,
/// Drop all overrides — the agent returns to the hive-wide
/// defaults. Required to clear limits, so that a `set-limits`
/// with a forgotten value can't silently reset the agent.
#[arg(
long,
conflicts_with_all = ["cpu_quota", "memory_max"],
required_unless_present_any = ["cpu_quota", "memory_max"],
)]
reset: bool,
},
/// Open an interactive Claude session inside this agent's container.
///
/// A fresh session by default, or resume a prior one. Requires root
/// and a running container.
Choom {
/// Resume a prior claude session by its session id, passed
/// through as `claude --resume <value>` (claude's `--continue`
/// takes no value — it resumes the cwd's latest session, which
/// is the harness's, so choom never uses it; this flag matches
/// the claude flag it maps to). Omit for a fresh blank session.
/// A value is required when the flag is given.
#[arg(long = "resume", value_name = "SESSION")]
resume_session: Option<String>,
},
/// Follow this agent's live turn/tool-call event stream from the CLI.
///
/// Prints one compact line per event as they happen. `Ctrl-C` to stop.
/// Requires the agent to be running.
Watch,
/// This agent's disk accounting + optional quota via btrfs qgroups.
///
/// Needs `hivectl quota-enable` run once hive-wide first. No-op on
/// non-btrfs hosts.
Quota {
#[command(subcommand)]
cmd: AgentQuotaCmd,
},
/// btrfs subvolume management for this agent's state dir.
///
/// Upgrade an existing plain-dir agent's state into a btrfs subvolume
/// so it gains snapshots and per-subvol usage/quota.
Subvol {
#[command(subcommand)]
cmd: SubvolCmd,
},
}
#[derive(Subcommand)]
pub enum AgentQuotaCmd {
/// Report this agent's disk usage from btrfs qgroups.
Show,
/// Set or clear this agent's disk-usage quota.
Set {
/// Size cap (`5G`, `500M`, `1073741824`) or `none` to clear.
size: String,
},
}
/// Operator approval queue: list, approve, or deny pending requests.
#[derive(Subcommand)]
pub enum ApprovalsCmd {
/// List pending approval requests submitted by agents.
Pending,
/// Approve a pending request by id; the action runs immediately.
Approve {
/// Approval id (from `hivectl approvals pending`).
id: i64,
},
/// Deny a pending request by id.
Deny {
/// Approval id.
id: i64,
},
}
#[derive(Subcommand)]
pub enum SubvolCmd {
/// Convert a plain-dir agent state root into a btrfs subvolume in
/// place, so it gains snapshots and per-subvol usage/quota.
///
/// Bounces the agent to migrate its state, so it requires `--yes`.
Upgrade {
/// Confirm: this stops the agent, migrates its state dir, and
/// restarts it. Required — the command refuses without it.
#[arg(long)]
yes: bool,
},
/// Read-only snapshots of this agent's state subvolume.
Snapshot {
#[command(subcommand)]
cmd: SnapshotCmd,
},
}
#[derive(Subcommand)]
pub enum SnapshotCmd {
/// Create a read-only snapshot (agent must already be a subvolume).
Create {
/// Snapshot label. Mandatory, and must start with `hive-` — the
/// prefix doubles as an allow-list hive-priv checks so only
/// hivectl-issued snapshot names can reach the `btrfs subvolume
/// snapshot` shellout.
#[arg(long)]
label: String,
},
/// Delete a snapshot created by `subvol snapshot create`.
Delete {
/// Snapshot label passed to `subvol snapshot create --label`.
label: String,
},
/// Export a snapshot to a local file via `btrfs send` (the local-file
/// half of inter-hive migration transport; the cross-hive `ssh ...
/// btrfs receive` leg isn't wired up yet). Also useful standalone as a
/// point-in-time backup: a full send with no `--parent` produces a
/// self-contained archive of the snapshot.
Send {
/// Snapshot label passed to `subvol snapshot create --label`.
label: String,
/// Optional parent snapshot label for an incremental send
/// (`btrfs send -p`) — must be an existing, older snapshot of the
/// same agent. Omit for a full send.
#[arg(long)]
parent: Option<String>,
/// Destination filename (not a path) under the migrate-staging
/// dir. Refused if it already exists.
#[arg(long)]
dest: String,
},
}