183 lines
6.9 KiB
Rust
183 lines
6.9 KiB
Rust
//! 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 <spec>` 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 <source>`. 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 `<plugin>@<marketplace>` install can resolve.
|
|
async fn add_marketplaces() {
|
|
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
|
|
return;
|
|
};
|
|
let sources: Vec<String> = 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::<bool>(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<String> = 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");
|
|
}
|
|
}
|