use anyhow::{bail, Context, Result};
use console::style;
use std::fs;
use std::process::Command;
use crate::trust::{determine_trust_tier, parse_source_ref, TrustTier};
use super::{
apply_git_auth, extract_name_from_source, link_commands, load_registry, normalize_source,
plugin_bin_dir, plugins_dir, run_build, run_hook, save_registry, validate_plugin_name,
PluginCapabilities, PluginEntry, PluginManifest, PLUGINS_INSTALL_SCHEMA,
};
pub(super) fn check_tier_capabilities(
tier: TrustTier,
caps: &PluginCapabilities,
) -> std::result::Result<(), Vec<&'static str>> {
if tier != TrustTier::Unverified {
return Ok(());
}
let mut blocked = Vec::new();
if caps.exec {
blocked.push("exec");
}
if caps.network {
blocked.push("network");
}
if blocked.is_empty() {
Ok(())
} else {
Err(blocked)
}
}
pub(crate) fn install_action(
source: Option<&str>,
force: bool,
defaults: bool,
json: bool,
) -> Result<()> {
if defaults {
if source.is_some() {
bail!("--defaults installs the curated set; do not pass a source ref alongside it.");
}
return install_defaults(force, json);
}
let source = source.ok_or_else(|| {
anyhow::anyhow!(
"Either pass a source ref (owner/repo[@ref]) or use --defaults to install the curated set."
)
})?;
let report = install_plugin(source, force, json)?;
if json {
let result = serde_json::json!({
"schema_version": PLUGINS_INSTALL_SCHEMA,
"action": "install",
"scope": "single",
"installed": [report],
"failed": [],
"summary": { "total": 1, "installed": 1, "failed": 0 },
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
Ok(())
}
pub(crate) fn install_defaults(force: bool, json: bool) -> Result<()> {
use super::DEFAULT_PLUGINS;
if !json {
println!(
"{} Installing {} default plugins...",
style("*").cyan().bold(),
DEFAULT_PLUGINS.len()
);
}
let mut installed: Vec<serde_json::Value> = Vec::new();
let mut installed_sources: Vec<&str> = Vec::new();
let mut failed: Vec<(&str, String)> = Vec::new();
for source in DEFAULT_PLUGINS {
if !json {
println!();
println!(" {} {}", style("→").dim(), style(source).cyan());
}
match install_plugin(source, force, json) {
Ok(report) => {
installed.push(report);
installed_sources.push(source);
}
Err(e) => failed.push((source, e.to_string())),
}
}
if !json {
println!();
println!(
"{} {} of {} default plugins installed.",
if failed.is_empty() {
style("✅").green().bold()
} else {
style("⚠️").yellow().bold()
},
installed_sources.len(),
DEFAULT_PLUGINS.len()
);
if !failed.is_empty() {
println!();
println!("Failures:");
for (source, err) in &failed {
println!(" {} {} — {}", style("✗").red(), style(source).cyan(), err);
}
}
}
if json {
let failed_json: Vec<serde_json::Value> = failed
.iter()
.map(|(source, err)| serde_json::json!({ "source": source, "error": err }))
.collect();
let result = serde_json::json!({
"schema_version": PLUGINS_INSTALL_SCHEMA,
"action": "install",
"scope": "defaults",
"installed": installed,
"failed": failed_json,
"summary": {
"total": DEFAULT_PLUGINS.len(),
"installed": installed_sources.len(),
"failed": failed.len(),
},
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
if !failed.is_empty() {
bail!("{} default plugin(s) failed to install.", failed.len());
}
Ok(())
}
pub(crate) fn install_plugin(source: &str, force: bool, json: bool) -> Result<serde_json::Value> {
let force = force || crate::utils::is_non_interactive();
let (_, git_ref) = parse_source_ref(source);
let url = normalize_source(source);
let repo_name = extract_name_from_source(source);
validate_plugin_name(&repo_name)?;
let tier = determine_trust_tier(source);
if !json {
println!(
"\n{} Installing plugin from: {} [{}]",
style("!").yellow().bold(),
style(&url).cyan(),
tier.styled_label()
);
if tier == TrustTier::Official {
println!(
" {} This is an official CorvidLabs plugin.",
style("✓").green()
);
} else {
println!(
" {} Plugins can execute arbitrary code on your system.",
style("*").yellow()
);
println!(
" {} Only install plugins from sources you trust.\n",
style("*").yellow()
);
}
}
if !force {
if !crate::utils::is_interactive() {
bail!(
"Plugin installation requires confirmation in non-interactive mode.\n \
Use --yes or --force to skip prompts."
);
}
let confirm = dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!("Install plugin '{repo_name}' from {url}?"))
.default(true)
.interact()?;
if !confirm {
bail!("Plugin installation cancelled.");
}
}
let plugins = plugins_dir();
let bin_dir = plugin_bin_dir();
fs::create_dir_all(&plugins)?;
fs::create_dir_all(&bin_dir)?;
let plugin_dir = plugins.join(&repo_name);
let mut registry = load_registry()?;
let existing = registry.plugins.iter().position(|p| p.name == repo_name);
if plugin_dir.exists() {
if !force {
bail!(
"Plugin '{}' is already installed.\n Use {} to reinstall.",
repo_name,
style("--force").cyan()
);
}
fs::remove_dir_all(&plugin_dir).context("removing existing plugin")?;
}
let sp = if json {
None
} else {
let clone_msg = match git_ref {
Some(r) => format!("Cloning {}@{}:", &url, r),
None => format!("Cloning {}:", &url),
};
Some(crate::spinner::Spinner::start(&clone_msg))
};
let mut clone_args = vec!["clone"];
if git_ref.is_none() {
clone_args.push("--depth");
clone_args.push("1");
}
clone_args.push(&url);
let mut cmd = Command::new("git");
cmd.args(&clone_args)
.arg(&plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
apply_git_auth(&mut cmd);
let status = cmd.status().context("running git clone")?;
if let Some(s) = sp {
s.finish();
}
if !status.success() {
bail!(
"Failed to clone '{}'. Check the repository URL and your network connection.",
source
);
}
if let Some(ref_str) = git_ref {
let status = Command::new("git")
.args(["checkout", ref_str])
.current_dir(&plugin_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()
.with_context(|| format!("checking out ref '{ref_str}'"))?;
if !status.success() {
fs::remove_dir_all(&plugin_dir).ok();
bail!(
"Git ref '{}' not found in '{}'. Check available tags with:\n {}",
ref_str,
source,
style(format!("git ls-remote --tags {url}")).cyan()
);
}
}
let manifest_path = plugin_dir.join("plugin.toml");
if !manifest_path.exists() {
fs::remove_dir_all(&plugin_dir).ok();
bail!(
"Repository '{}' has no plugin.toml manifest.\n See {} for the plugin format.",
source,
style("https://github.com/CorvidLabs/fledge#plugins").cyan()
);
}
let manifest_content = fs::read_to_string(&manifest_path).context("reading plugin.toml")?;
let manifest: PluginManifest =
toml::from_str(&manifest_content).context("parsing plugin.toml")?;
let caps = &manifest.capabilities;
let has_protocol_caps = caps.exec || caps.store || caps.metadata;
let has_wasm_caps = caps.filesystem.as_deref().is_some_and(|f| f != "none") || caps.network;
let has_caps = has_protocol_caps || has_wasm_caps;
let needs_cap_prompt =
has_caps && (manifest.plugin.protocol.is_some() || manifest.plugin.is_wasm());
let has_hooks = manifest.hooks.has_any();
if let Err(blocked) = check_tier_capabilities(tier, caps) {
if let Err(e) = fs::remove_dir_all(&plugin_dir) {
eprintln!(
"Warning: failed to clean up partial install at {}: {e}",
plugin_dir.display()
);
}
bail!(
"Unverified plugin '{}' requests dangerous capabilities: {}\n \
Only official and team-tier plugins may use exec or network.\n \
If you trust this source, fork it under an account you control or an org in your team allowlist.",
repo_name,
blocked.join(", ")
);
}
if needs_cap_prompt || has_hooks {
if !json {
if needs_cap_prompt {
println!("\n {} Requested capabilities:", style("*").cyan().bold());
if caps.exec {
println!(" {} exec — run shell commands", style("•").yellow());
}
if caps.store {
println!(
" {} store — persist data between runs",
style("•").yellow()
);
}
if caps.metadata {
println!(
" {} metadata — read project metadata and environment",
style("•").yellow()
);
}
if let Some(ref fs_cap) = caps.filesystem {
match fs_cap.as_str() {
"project" => {
println!(
" {} filesystem (project) — read-only access to project directory",
style("•").yellow()
);
}
"plugin" => {
println!(
" {} filesystem (plugin) — read-only project access + read-write plugin data",
style("•").yellow()
);
}
"none" => {}
other => {
println!(
" {} filesystem ({}) — access host files",
style("•").yellow(),
other
);
}
}
}
if caps.network {
println!(
" {} network — make outbound network requests (unrestricted)",
style("•").yellow()
);
}
if caps.exec && caps.network {
println!(
"\n {} This plugin can both execute commands and access the network.",
style("⚠").yellow().bold()
);
println!(
" {} Together these allow data exfiltration — only install if you trust the source.",
style("⚠").yellow().bold()
);
}
}
if has_hooks {
println!("\n {} Lifecycle hooks:", style("*").cyan().bold());
for (name, cmd) in manifest.hooks.iter_defined() {
println!(
" {} {} — {}",
style("•").yellow(),
name,
style(cmd).dim()
);
}
}
println!();
}
if force {
eprintln!(
" {} Permissions auto-granted via --force",
style("WARN").yellow()
);
} else if !crate::utils::is_interactive() {
fs::remove_dir_all(&plugin_dir).ok();
bail!(
"Plugin permissions require confirmation in non-interactive mode.\n \
Use --yes or --force to auto-grant."
);
} else {
let prompt_msg = if needs_cap_prompt && has_hooks {
"Grant capabilities and approve hooks?"
} else if needs_cap_prompt {
"Grant these capabilities?"
} else {
"Approve these hooks?"
};
let confirm =
dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt_msg)
.default(true)
.interact()?;
if !confirm {
fs::remove_dir_all(&plugin_dir).ok();
bail!("Plugin installation cancelled.");
}
}
}
run_build(&plugin_dir, &manifest)?;
if manifest.plugin.is_wasm() {
for cmd in &manifest.commands {
let wasm_path = plugin_dir.join(&cmd.binary);
if wasm_path.exists() {
println!(
" {} Pre-compiling WASM module...",
style("▶").cyan().bold()
);
super::wasm::compile_and_cache(&wasm_path)?;
} else {
fs::remove_dir_all(&plugin_dir).ok();
bail!(
"WASM binary '{}' not found after build.\n \
Check that the build hook produces a .wasm file at the path declared in plugin.toml.\n \
Expected: {}",
cmd.binary,
wasm_path.display()
);
}
}
}
let command_names = link_commands(&plugin_dir, &bin_dir, &manifest).inspect_err(|_| {
fs::remove_dir_all(&plugin_dir).ok();
})?;
let (base_source, _) = parse_source_ref(source);
let granted_caps = if manifest.plugin.protocol.is_some() {
Some(manifest.capabilities.clone())
} else {
None
};
let entry = PluginEntry {
name: repo_name.clone(),
source: base_source.to_string(),
version: manifest.plugin.version.clone(),
installed: chrono::Local::now().format("%Y-%m-%d").to_string(),
commands: command_names.clone(),
pinned_ref: git_ref.map(String::from),
capabilities: granted_caps,
runtime: manifest.plugin.runtime.clone(),
};
if let Some(idx) = existing {
registry.plugins[idx] = entry.clone();
} else {
registry.plugins.push(entry.clone());
}
save_registry(®istry)?;
if !json {
if let Some(ref pinned) = git_ref {
println!(
"{} Installed {} v{} (pinned to {})",
style("✅").green().bold(),
style(&manifest.plugin.name).green(),
manifest.plugin.version,
style(pinned).cyan()
);
} else {
println!(
"{} Installed {} v{}",
style("✅").green().bold(),
style(&manifest.plugin.name).green(),
manifest.plugin.version
);
}
if !command_names.is_empty() {
println!(" Commands: {}", style(command_names.join(", ")).cyan());
}
}
if let Some(hook) = &manifest.hooks.post_install {
run_hook(&plugin_dir, hook, "post_install")?;
}
Ok(serde_json::json!({
"name": entry.name,
"source": entry.source,
"version": entry.version,
"trust_tier": tier.label(),
"commands": entry.commands,
"pinned_ref": entry.pinned_ref,
"capabilities": entry.capabilities,
}))
}