//! Boot-time `claude plugin install` driver. Reads the list declared //! via the `hyperhive.claudePlugins` NixOS option (rendered to //! `/etc/hyperhive/claude-plugins.json` by the harness module) and //! shells out `claude plugin install ` for each entry. Runs once //! per harness boot before the turn loop; `claude plugin install` //! is expected to be idempotent so reinstalling on each container //! recreate is fine. Failures log a warning but do not abort boot — //! we'd rather start without a plugin than refuse to serve. //! //! Before installing, all configured marketplaces are updated so that //! plugin specs resolve against current index data. Marketplace update //! failures are non-fatal — stale index is better than no install attempt. use std::path::Path; use tokio::process::Command; use crate::client; const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json"; const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json"; const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json"; /// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json` /// via `claude plugin marketplace add `. Idempotent: re-add of /// an existing marketplace is treated as success (claude prints an /// "already exists" message and exits non-zero on some versions). /// Required before any `@` install can resolve. async fn add_marketplaces() { let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else { return; }; let sources: Vec = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping"); return; } }; for source in sources { match Command::new("claude") .args(["plugin", "marketplace", "add", &source]) .output() .await { Ok(out) if out.status.success() => { tracing::info!(source = %source, "claude plugin marketplace add ok"); } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("already") { tracing::debug!(source = %source, "marketplace already added"); } else { tracing::warn!( source = %source, status = ?out.status, stderr = %stderr, "claude plugin marketplace add failed (non-fatal)", ); } } Err(e) => { tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed"); } } } } /// Read the `hyperhive.claudePluginsAutoUpdate` flag written by the NixOS /// module. Defaults to `false` when the file is absent or unparseable. async fn auto_update_enabled() -> bool { match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await { Ok(s) => serde_json::from_str::(s.trim()).unwrap_or(false), Err(_) => false, } } /// Update all configured plugin marketplaces. Non-fatal — logs a warning /// on failure but does not abort the install sequence. async fn update_marketplaces() { match Command::new("claude") .args(["plugin", "marketplace", "update"]) .output() .await { Ok(out) if out.status.success() => { tracing::info!("claude plugin marketplace update ok"); } Ok(out) => { tracing::warn!( status = ?out.status, stderr = %String::from_utf8_lossy(&out.stderr), "claude plugin marketplace update failed (non-fatal)", ); } Err(e) => { tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)"); } } } /// Install every plugin in `/etc/hyperhive/claude-plugins.json`. When /// `notify_recipient` is `Some(name)`, install failures also get sent /// as a hyperhive message to that recipient (typically `"manager"` for /// sub-agents) so it surfaces in the inbox rather than being buried in /// journald. The manager itself passes `None` — there's nobody above /// it to notify. pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) { let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else { return; }; let specs: Vec = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping"); return; } }; if specs.is_empty() { return; } add_marketplaces().await; if auto_update_enabled().await { update_marketplaces().await; } else { tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update"); } for spec in specs { match Command::new("claude") .args(["plugin", "install", &spec]) .output() .await { Ok(out) if out.status.success() => { tracing::info!(spec = %spec, "claude plugin install ok"); } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); tracing::warn!( spec = %spec, status = ?out.status, stderr = %stderr, "claude plugin install failed", ); if let Some(to) = notify_recipient { notify( socket, to, format!( "claude plugin install failed for `{spec}`:\n{}", stderr.trim() ), ) .await; } } Err(e) => { tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed"); if let Some(to) = notify_recipient { notify( socket, to, format!("claude plugin install spawn failed for `{spec}`: {e}"), ) .await; } } } } } /// Best-effort hyperhive send. Swallows transport errors — the warn log /// is already in journald and the harness boot must not stall waiting /// for the broker to be reachable. async fn notify(socket: &Path, to: &str, body: String) { let req = hive_sh4re::AgentRequest::Send { to: to.to_owned(), body, }; if let Err(e) = client::request::<_, hive_sh4re::AgentResponse>(socket, &req).await { tracing::warn!(error = ?e, "failed to notify {to} of plugin install failure"); } }