grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/plugin_cmd.rs

1199 lines
40 KiB
Rust
Raw Normal View History

//! `grok plugin` CLI subcommand — manage plugins and marketplace sources.
//!
//! Follows the `memory_cmd.rs` / `sessions_cmd.rs` / `worktree_cmd` pattern:
//! clap args and handler logic co-located in a dedicated module. The pager's
//! `main.rs` dispatches here with a one-liner.
//!
//! Business logic lives in `xai_grok_shell::plugin` (shared orchestration)
//! and lower crates (`xai-grok-agent`, `xai-grok-plugin-marketplace`). This
//! module is a thin CLI wrapper: parse args, call ops, format output, emit
//! telemetry.
use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use clap::Subcommand;
use serde::Serialize;
use xai_grok_agent::plugins::install_registry::{InstallKind, InstallRegistry};
use xai_grok_agent::plugins::manifest::{ManifestLoadResult, PluginManifest, load_manifest};
use xai_grok_plugin_marketplace::SourceKind;
use xai_grok_plugin_marketplace::git::SourceCacheLease;
use xai_grok_shell::plugin::{self, RepoUpdateOutcome, UninstallError};
// ── JSON output types ───────────────────────────────────────────────
/// Typed entry for `grok plugin list --json`. The `status` field acts as a
/// discriminator: `"installed"` entries have repo/path fields, `"available"`
/// entries have description/component fields.
#[derive(Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum PluginEntry {
Installed {
name: String,
repo_key: String,
version: Option<String>,
path: PathBuf,
source: String,
marketplace: Option<String>,
},
Available {
name: String,
version: Option<String>,
description: Option<String>,
marketplace: String,
skill_count: usize,
has_hooks: bool,
has_agents: bool,
has_mcp: bool,
#[serde(skip_serializing_if = "Option::is_none")]
components: Option<xai_hooks_plugins_types::PluginComponents>,
},
}
/// Typed entry for `grok plugin marketplace list --json`.
#[derive(Serialize)]
struct MarketplaceSourceEntry {
name: String,
kind: String,
source: MarketplaceSourceDetail,
}
#[derive(Serialize)]
#[serde(untagged)]
enum MarketplaceSourceDetail {
Git { url: String, branch: Option<String> },
Local { path: PathBuf },
}
impl MarketplaceSourceDetail {
fn kind(&self) -> &'static str {
match self {
Self::Git { .. } => "git",
Self::Local { .. } => "local",
}
}
}
// ── CLI arg definitions ─────────────────────────────────────────────
#[derive(Debug, clap::Args, Clone)]
pub struct PluginArgs {
#[command(subcommand)]
pub command: PluginCommand,
}
#[derive(Debug, Subcommand, Clone)]
pub enum PluginCommand {
/// List installed plugins
List {
/// Emit machine-readable JSON output.
#[arg(long)]
json: bool,
/// Include available plugins from marketplace sources. Requires --json.
#[arg(long, requires = "json")]
available: bool,
},
/// Install a plugin from a git URL or local path
Install {
/// Git URL, GitHub shorthand (user/repo), or local path.
/// Supports @ref suffix (e.g. user/repo@v1.0) and #subdir.
source: String,
/// Trust the plugin immediately (skip confirmation prompt).
#[arg(long)]
trust: bool,
},
/// Uninstall an installed plugin by name
#[command(visible_alias = "rm", visible_alias = "remove")]
Uninstall {
/// Plugin name (as shown by `grok plugin list`).
name: String,
/// Skip confirmation for multi-plugin repos.
#[arg(long)]
confirm: bool,
/// Preserve the plugin's persistent data directory.
#[arg(long)]
keep_data: bool,
},
/// Update installed plugin(s)
Update {
/// Plugin name to update. Omit to update all.
name: Option<String>,
},
/// Enable a disabled plugin
Enable {
/// Plugin name to enable.
name: String,
},
/// Disable a plugin without uninstalling it
Disable {
/// Plugin name to disable.
name: String,
},
/// Show a plugin's component inventory
Details {
/// Plugin name.
name: String,
},
/// Validate a plugin manifest
Validate {
/// Path to plugin directory (default: current directory).
#[arg(default_value = ".")]
path: String,
},
/// Create a release git tag from the plugin's manifest version
Tag {
/// Path to plugin directory (default: current directory).
#[arg(default_value = ".")]
path: String,
/// Push the tag to the remote after creating it.
#[arg(long)]
push: bool,
/// Create the tag even if the working tree is dirty or tag exists.
#[arg(long, short = 'f')]
force: bool,
/// Print what would be tagged without creating the tag.
#[arg(long)]
dry_run: bool,
},
/// Manage marketplace sources
Marketplace(MarketplaceArgs),
}
#[derive(Debug, clap::Args, Clone)]
pub struct MarketplaceArgs {
#[command(subcommand)]
pub command: MarketplaceCommand,
}
#[derive(Debug, Subcommand, Clone)]
pub enum MarketplaceCommand {
/// List configured marketplace sources and their plugins
List {
/// Emit machine-readable JSON output.
#[arg(long)]
json: bool,
},
/// Add a marketplace source (git URL, GitHub shorthand, or local path)
Add {
/// Git URL, GitHub shorthand (e.g. user/repo), or local directory path.
url: String,
},
/// Remove a marketplace source and uninstall its plugins
Remove {
/// Git URL or local path of the source to remove.
url: String,
},
/// Refresh marketplace source(s) and sync git caches
Update {
/// Source URL to refresh. Omit to refresh all.
name: Option<String>,
},
}
// ── Helpers ─────────────────────────────────────────────────────────
fn kind_label(kind: &InstallKind) -> String {
match kind {
InstallKind::Git { url, .. } => format!("git: {url}"),
InstallKind::Local { source_path, .. } => format!("local: {}", source_path.display()),
}
}
fn print_component_summary(manifest: &PluginManifest, root: &Path) {
let skills = manifest.skill_dirs(root);
let commands = manifest.command_dirs(root);
let agents = manifest.agent_dirs(root);
let has_hooks = manifest.hooks_path(root).is_some() || manifest.inline_hooks().is_some();
let has_mcp =
manifest.mcp_config_path(root).is_some() || manifest.inline_mcp_servers().is_some();
let has_lsp =
manifest.lsp_config_path(root).is_some() || manifest.inline_lsp_servers().is_some();
println!(
" components: {} skill dir(s), {} command dir(s), {} agent dir(s){}{}{}",
skills.len(),
commands.len(),
agents.len(),
if has_hooks { ", hooks" } else { "" },
if has_mcp { ", MCP servers" } else { "" },
if has_lsp { ", LSP servers" } else { "" },
);
}
fn abbreviated_commit(c: Option<&str>) -> &str {
c.map(|s| &s[..7.min(s.len())]).unwrap_or("?")
}
fn trust_prompt(subject: &str, source_arg: &str) -> String {
format!(
"Installing {subject} requires confirmation.\n\
Plugins can run hooks, MCP servers, and skills on your machine, so installation needs explicit trust.\n\
\n\
To proceed, re-run with --trust:\n grok plugin install {source_arg} --trust"
)
}
// ── Top-level dispatch ──────────────────────────────────────────────
pub async fn run(args: PluginArgs) -> Result<()> {
match args.command {
PluginCommand::List { json, available } => cmd_list(json, available),
PluginCommand::Install { source, trust } => cmd_install(&source, trust),
PluginCommand::Uninstall {
name,
confirm,
keep_data,
} => cmd_uninstall(&name, confirm, keep_data),
PluginCommand::Update { name } => cmd_update(name.as_deref()),
PluginCommand::Enable { name } => cmd_enable(&name),
PluginCommand::Disable { name } => cmd_disable(&name),
PluginCommand::Details { name } => cmd_details(&name),
PluginCommand::Validate { path } => cmd_validate(&path),
PluginCommand::Tag {
path,
push,
force,
dry_run,
} => cmd_tag(&path, push, force, dry_run),
PluginCommand::Marketplace(mp) => run_marketplace(mp.command).await,
}
}
// ── Plugin subcommands ──────────────────────────────────────────────
fn cmd_list(json: bool, available: bool) -> Result<()> {
let registry = InstallRegistry::load();
let repos = registry.list();
if json {
let mut entries = installed_plugins(&repos);
if available {
entries.extend(available_plugins(&registry));
}
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if repos.is_empty() {
println!("No plugins installed. Run `grok plugin install --help` to get started.");
} else {
for (repo_key, repo) in &repos {
let mp = repo
.marketplace
.as_ref()
.map(|mp| format!(" ({})", mp.source_display_name))
.unwrap_or_default();
let names: Vec<&str> = repo.plugins.keys().map(|s| s.as_str()).collect();
println!(
" {repo_key}: {} [{}]{mp}",
names.join(", "),
kind_label(&repo.kind)
);
}
}
Ok(())
}
fn installed_plugins(
repos: &[(
&str,
&xai_grok_agent::plugins::install_registry::InstalledRepo,
)],
) -> Vec<PluginEntry> {
repos
.iter()
.flat_map(|(repo_key, repo)| {
let source = match &repo.kind {
InstallKind::Git { url, .. } => url.clone(),
InstallKind::Local { source_path, .. } => source_path.display().to_string(),
};
let marketplace = repo
.marketplace
.as_ref()
.map(|mp| mp.source_display_name.clone());
repo.plugins
.iter()
.map(move |(name, plugin)| PluginEntry::Installed {
name: name.clone(),
repo_key: repo_key.to_string(),
version: plugin.version.clone(),
path: repo.path.clone(),
source: source.clone(),
marketplace: marketplace.clone(),
})
})
.collect()
}
fn available_plugins(registry: &InstallRegistry) -> Vec<PluginEntry> {
let config = xai_grok_shell::config::load_effective_config()
.ok()
.unwrap_or(toml::Value::Table(toml::map::Map::new()));
let mut sources = xai_grok_plugin_marketplace::load_sources(&config);
sources.extend(xai_grok_plugin_marketplace::load_extra_sources_from_settings(&sources));
let mut entries = Vec::new();
for source in &sources {
let identity = source_identity(source);
let root = resolve_marketplace_root(source);
let Some((root, lease)) = root else { continue };
for plugin in xai_grok_plugin_marketplace::scan_marketplace(&root).entries {
let already_installed =
xai_grok_plugin_marketplace::installer::find_installed_marketplace_plugin(
registry,
&identity,
&plugin.relative_path,
)
.is_some();
if already_installed {
continue;
}
entries.push(PluginEntry::Available {
name: plugin.name,
version: plugin.version,
description: plugin.description,
marketplace: source.name.clone(),
skill_count: plugin.skill_count,
has_hooks: plugin.has_hooks,
has_agents: plugin.has_agents,
has_mcp: plugin.has_mcp,
components: plugin.components,
});
}
drop(lease);
}
entries
}
fn source_identity(source: &xai_grok_plugin_marketplace::MarketplaceSource) -> String {
match &source.kind {
SourceKind::Git { url, .. } => url.clone(),
SourceKind::Local { path } => path.display().to_string(),
}
}
fn resolve_marketplace_root(
source: &xai_grok_plugin_marketplace::MarketplaceSource,
) -> Option<(std::path::PathBuf, Option<SourceCacheLease>)> {
match &source.kind {
SourceKind::Local { path } if path.is_dir() => Some((path.clone(), None)),
SourceKind::Git { url, branch } => {
let cache = xai_grok_plugin_marketplace::git::default_cache_root();
xai_grok_plugin_marketplace::git::sync_source_cache_with_mode(
url,
branch.as_deref(),
&cache,
xai_grok_plugin_marketplace::git::SyncMode::UseTtl,
)
.map(|lease| (lease.path.clone(), Some(lease)))
.map_err(|e| tracing::warn!("failed to sync marketplace {url}: {e}"))
.ok()
}
_ => None,
}
}
fn install_kind(is_git: bool) -> xai_grok_telemetry::events::InstallKind {
if is_git {
xai_grok_telemetry::events::InstallKind::Git
} else {
xai_grok_telemetry::events::InstallKind::Local
}
}
fn log_plugin_installed(
install_kind: xai_grok_telemetry::events::InstallKind,
success: bool,
error_category: Option<String>,
) {
xai_grok_telemetry::session_ctx::log_event(xai_grok_telemetry::events::PluginInstalled {
install_kind,
success,
trust: true,
error_category,
});
}
fn cmd_install(source: &str, trust: bool) -> Result<()> {
if let Some(mref) = xai_grok_plugin_marketplace::install_resolve::parse_marketplace_ref(source)
{
return cmd_install_marketplace(source, &mref, trust);
}
let cwd = std::env::current_dir().unwrap_or_default();
if !trust {
use xai_grok_agent::plugins::git_install::{self, InstallSource};
let subject = match git_install::parse_install_source(source, &cwd) {
InstallSource::Git { url, .. } => format!("from git repo {url}"),
InstallSource::Local { path, .. } => format!("from directory {}", path.display()),
};
eprintln!("{}", trust_prompt(&subject, source));
std::process::exit(1);
}
match plugin::install_plugin(source, &cwd) {
Ok(outcome) => {
for w in &outcome.warnings {
tracing::warn!("{w}");
}
log_plugin_installed(install_kind(!outcome.is_local), true, None);
println!(
"Installed {} plugin(s) from {source}: {}",
outcome.plugin_names.len(),
outcome.plugin_names.join(", "),
);
Ok(())
}
Err(e) => {
let cat = plugin::classify_install_error(&e);
// On failure we don't know the kind; default to Git (matches canonical).
log_plugin_installed(
xai_grok_telemetry::events::InstallKind::Git,
false,
Some(cat),
);
bail!("{e}");
}
}
}
fn cmd_install_marketplace(
source: &str,
mref: &xai_grok_plugin_marketplace::install_resolve::MarketplaceRef,
trust: bool,
) -> Result<()> {
if !trust {
let from = match &mref.qualifier {
Some(qualifier) => match plugin::resolve_qualified_source_name(qualifier) {
Ok(_display) => qualifier.clone(),
Err(e) => bail!("{e}"),
},
None => match plugin::resolve_marketplace_source_name(&mref.name, None) {
Ok(display) => display,
Err(e) => bail!("{e}"),
},
};
let subject = format!("\"{}\" from marketplace \"{from}\"", mref.name);
eprintln!("{}", trust_prompt(&subject, source));
std::process::exit(1);
}
match plugin::install_marketplace_plugin(&mref.name, mref.qualifier.as_deref()) {
Ok(outcome) => {
for w in &outcome.warnings {
tracing::warn!("{w}");
}
if outcome.already_installed {
log_plugin_installed(
install_kind(outcome.source_is_git),
false,
Some("already_installed".to_string()),
);
let update_name = outcome
.plugin_names
.first()
.map(String::as_str)
.unwrap_or(&mref.name);
println!(
"Plugin \"{}\" is already installed from {}. \
Run `grok plugin update {}` to update it.",
mref.name, outcome.source_display_name, update_name,
);
return Ok(());
}
log_plugin_installed(install_kind(outcome.source_is_git), true, None);
if let Some(note) = &outcome.other_copies_note {
println!("{note}");
}
println!(
"Installed {} plugin(s) from {}: {}",
outcome.plugin_names.len(),
outcome.source_display_name,
outcome.plugin_names.join(", "),
);
Ok(())
}
Err(e) => {
// On failure we don't know the kind; default to Git (matches canonical).
log_plugin_installed(
xai_grok_telemetry::events::InstallKind::Git,
false,
Some(e.category()),
);
bail!("{e}");
}
}
}
fn cmd_uninstall(name: &str, confirm: bool, keep_data: bool) -> Result<()> {
match plugin::uninstall_plugin(name, confirm, keep_data) {
Ok(outcome) => {
xai_grok_telemetry::session_ctx::log_event(
xai_grok_telemetry::events::PluginUninstalled {
confirmed: true,
success: true,
},
);
let suffix = if keep_data { " (data preserved)" } else { "" };
println!(
"Uninstalled {} plugin(s): {}{suffix}",
outcome.removed_plugins.len(),
outcome.removed_plugins.join(", "),
);
Ok(())
}
Err(UninstallError::NeedsConfirm {
name,
repo_key,
other_plugins,
total,
}) => bail!(
"Plugin \"{name}\" belongs to repo \"{repo_key}\" which also contains:\n\
{}\n\n\
Uninstalling will remove all {total} plugin(s). To proceed:\n\
grok plugin uninstall {name} --confirm",
other_plugins
.iter()
.map(|p| format!(" - {p}"))
.collect::<Vec<_>>()
.join("\n"),
),
Err(e @ UninstallError::NotFound { .. }) => bail!("{e}"),
}
}
fn cmd_update(name: Option<&str>) -> Result<()> {
let outcomes = plugin::update_plugins(name).map_err(|e| anyhow::anyhow!("{e}"))?;
if outcomes.is_empty() {
println!("No installed plugins to update.");
return Ok(());
}
for o in &outcomes {
match o {
RepoUpdateOutcome::Updated {
repo_key,
old_commit,
new_commit,
} => {
println!(
"{repo_key}: updated ({} -> {})",
abbreviated_commit(old_commit.as_deref()),
abbreviated_commit(new_commit.as_deref()),
);
}
RepoUpdateOutcome::AlreadyUpToDate { repo_key } => {
println!("{repo_key}: already up to date");
}
RepoUpdateOutcome::Pinned { repo_key, ref_name } => {
println!("{repo_key}: pinned to {ref_name}, skipping");
}
RepoUpdateOutcome::LiveLocal { repo_key } => {
println!("{repo_key}: local symlink, already live");
}
RepoUpdateOutcome::Failed { repo_key, error } => {
eprintln!("{repo_key}: update failed: {error}");
}
}
}
Ok(())
}
fn cmd_enable(name: &str) -> Result<()> {
let registry = InstallRegistry::load();
if registry.find_plugin(name).is_none() {
bail!(
"Plugin \"{name}\" not found.\n\
Run `grok plugin list` to see installed plugins."
);
}
if let Err(e) = xai_grok_shell::config::remove_disabled_plugin(name) {
tracing::warn!("failed to remove from disabled list: {e}");
}
xai_grok_shell::config::add_enabled_plugin(name)
.map_err(|e| anyhow::anyhow!("Failed to enable plugin: {e}"))?;
println!("Enabled plugin: {name}");
Ok(())
}
fn cmd_disable(name: &str) -> Result<()> {
let registry = InstallRegistry::load();
if registry.find_plugin(name).is_none() {
bail!(
"Plugin \"{name}\" not found.\n\
Run `grok plugin list` to see installed plugins."
);
}
if let Err(e) = xai_grok_shell::config::remove_enabled_plugin(name) {
tracing::warn!("failed to remove from enabled list: {e}");
}
xai_grok_shell::config::add_disabled_plugin(name)
.map_err(|e| anyhow::anyhow!("Failed to disable plugin: {e}"))?;
println!("Disabled plugin: {name}");
Ok(())
}
fn cmd_details(name: &str) -> Result<()> {
let registry = InstallRegistry::load();
let (repo_key, repo, _) = registry.find_plugin(name).ok_or_else(|| {
anyhow::anyhow!(
"Plugin \"{name}\" not found.\n\
Run `grok plugin list` to see installed plugins."
)
})?;
let mp = repo
.marketplace
.as_ref()
.map(|mp| format!("\n source: {}", mp.source_display_name))
.unwrap_or_default();
println!("{repo_key}");
println!(" path: {}", repo.path.display());
println!(" kind: {}{mp}", kind_label(&repo.kind));
println!(" installed: {}", repo.installed_at);
println!(" updated: {}", repo.updated_at);
println!(" plugins ({}):", repo.plugins.len());
for (pname, p) in &repo.plugins {
let ver = p
.version
.as_deref()
.map(|v| format!(" v{v}"))
.unwrap_or_default();
let sub = p
.subdir
.as_deref()
.map(|s| format!(" (subdir: {s})"))
.unwrap_or_default();
println!(" {pname}{ver}{sub}");
}
if let Ok(ManifestLoadResult::Found(manifest)) = load_manifest(&repo.path) {
if let Some(ref desc) = manifest.description {
println!(" description: {desc}");
}
print_component_summary(&manifest, &repo.path);
}
Ok(())
}
fn cmd_validate(path: &str) -> Result<()> {
let root = PathBuf::from(path);
if !root.is_dir() {
bail!("Not a directory: {path}");
}
match load_manifest(&root) {
Ok(ManifestLoadResult::Found(manifest)) => {
manifest
.validate()
.map_err(|e| anyhow::anyhow!("Manifest validation failed: {e}"))?;
println!("Plugin manifest is valid.");
println!(" name: {}", manifest.name);
if let Some(ref v) = manifest.version {
println!(" version: {v}");
}
if let Some(ref d) = manifest.description {
println!(" description: {d}");
}
print_component_summary(&manifest, &root);
Ok(())
}
Ok(ManifestLoadResult::NotFound) => {
println!(
"No plugin.json found. Grok discovers skills, agents, and hooks \
automatically from standard directories. A manifest is only needed \
for custom paths or metadata."
);
Ok(())
}
Err(e) => bail!("Failed to load manifest: {e}"),
}
}
fn cmd_tag(path: &str, push: bool, force: bool, dry_run: bool) -> Result<()> {
let root = PathBuf::from(path);
if !root.is_dir() {
bail!("Not a directory: {path}");
}
let version = match load_manifest(&root) {
Ok(ManifestLoadResult::Found(m)) => m.version.ok_or_else(|| {
anyhow::anyhow!(
"No `version` field in plugin.json. Set a version to use `grok plugin tag`."
)
})?,
Ok(ManifestLoadResult::NotFound) => bail!("No plugin.json found in {path}."),
Err(e) => bail!("Failed to load manifest: {e}"),
};
let tag = format!(
"v{}",
version
.strip_prefix('v')
.or_else(|| version.strip_prefix('V'))
.unwrap_or(&version)
);
if !force {
let out = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&root)
.output()?;
if !out.stdout.is_empty() {
bail!("Working tree is dirty. Commit changes first, or use --force.");
}
}
if dry_run {
println!("Would create tag: {tag}");
if push {
println!("Would push tag to remote.");
}
return Ok(());
}
let mut cmd = std::process::Command::new("git");
cmd.args(["tag", &tag]);
if force {
cmd.arg("--force");
}
let out = cmd.current_dir(&root).output()?;
if !out.status.success() {
bail!(
"Failed to create tag: {}",
String::from_utf8_lossy(&out.stderr)
);
}
println!("Created tag: {tag}");
if push {
let mut push_cmd = std::process::Command::new("git");
push_cmd.args(["push", "origin", &tag]);
if force {
push_cmd.arg("--force");
}
let out = push_cmd.current_dir(&root).output()?;
if !out.status.success() {
bail!(
"Failed to push tag: {}",
String::from_utf8_lossy(&out.stderr)
);
}
println!("Pushed tag {tag} to origin.");
}
Ok(())
}
// ── Marketplace subcommands ─────────────────────────────────────────
async fn run_marketplace(cmd: MarketplaceCommand) -> Result<()> {
let config = xai_grok_shell::config::load_effective_config()
.ok()
.unwrap_or(toml::Value::Table(toml::map::Map::new()));
let mut sources = xai_grok_plugin_marketplace::load_sources(&config);
sources.extend(xai_grok_plugin_marketplace::load_extra_sources_from_settings(&sources));
match cmd {
MarketplaceCommand::List { json } => marketplace_list(&sources, json),
MarketplaceCommand::Add { url } => marketplace_add(&sources, &url),
MarketplaceCommand::Remove { url } => marketplace_remove(&sources, &url),
MarketplaceCommand::Update { name } => marketplace_update(&sources, name.as_deref()),
}
}
fn marketplace_list(
sources: &[xai_grok_plugin_marketplace::MarketplaceSource],
json: bool,
) -> Result<()> {
if json {
let entries: Vec<MarketplaceSourceEntry> = sources
.iter()
.map(|s| {
let detail = match &s.kind {
SourceKind::Git { url, branch } => MarketplaceSourceDetail::Git {
url: url.clone(),
branch: branch.clone(),
},
SourceKind::Local { path } => {
MarketplaceSourceDetail::Local { path: path.clone() }
}
};
MarketplaceSourceEntry {
name: s.name.clone(),
kind: detail.kind().to_string(),
source: detail,
}
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if sources.is_empty() {
println!(
"No marketplace sources configured.\n\
Run `grok plugin marketplace add --help` to get started."
);
} else {
for s in sources {
let id = match &s.kind {
SourceKind::Git { url, .. } => url.clone(),
SourceKind::Local { path } => path.display().to_string(),
};
println!(" {}: {id}", s.name);
}
}
Ok(())
}
fn marketplace_add(
sources: &[xai_grok_plugin_marketplace::MarketplaceSource],
url: &str,
) -> Result<()> {
use xai_grok_shell::plugin::MarketplaceAddInput;
let url = url.trim();
if url.is_empty() {
bail!("URL cannot be empty.");
}
let cwd = std::env::current_dir().unwrap_or_default();
let input = plugin::classify_marketplace_add_input(url, &cwd);
// Fail fast on missing local paths: without this, a path input would be
// stored as a git URL and only error after network clone attempts.
if let MarketplaceAddInput::LocalPath(path) = &input
&& !path.is_dir()
{
bail!(
"Local marketplace path not found (or is not a directory): {}",
path.display()
);
}
let identity = match &input {
MarketplaceAddInput::GitUrl(u) => u.clone(),
MarketplaceAddInput::LocalPath(p) => p.display().to_string(),
};
// Local paths never match the git-URL allowlist, so a restricted
// strictKnownMarketplaces policy blocks them — intentionally fail-closed.
let allowlist =
&xai_grok_workspace::permission::resolution::managed_settings().marketplace_allowlist;
if allowlist.is_restricted() && !allowlist.is_url_allowed(&identity) {
bail!("Marketplace source blocked: {}", allowlist.block_reason());
}
let already_configured = match &input {
MarketplaceAddInput::GitUrl(git_url) => {
let normalized = git_url.trim_end_matches(".git");
sources.iter().any(|s| {
matches!(&s.kind, SourceKind::Git { url: u, .. }
if u.trim_end_matches(".git") == normalized)
})
}
MarketplaceAddInput::LocalPath(path) => sources.iter().any(|s| {
matches!(&s.kind, SourceKind::Local { path: p }
if p == path)
}),
};
if already_configured {
bail!("Marketplace source already configured: {identity}");
}
let name = match &input {
MarketplaceAddInput::GitUrl(u) => plugin::name_from_url(u),
MarketplaceAddInput::LocalPath(p) => plugin::name_from_path(p),
};
let config_path = xai_grok_config::grok_home().join("config.toml");
let content = std::fs::read_to_string(&config_path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse config.toml: {e}"))?;
if doc.get("marketplace").is_none() {
doc["marketplace"] = toml_edit::Item::Table(toml_edit::Table::new());
}
if doc["marketplace"].get("sources").is_none() {
doc["marketplace"]["sources"] =
toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
}
let sources = doc["marketplace"]["sources"]
.as_array_of_tables_mut()
.ok_or_else(|| anyhow::anyhow!("marketplace.sources is not an array of tables"))?;
let mut entry = toml_edit::Table::new();
entry["name"] = toml_edit::value(&name);
match &input {
MarketplaceAddInput::GitUrl(git_url) => {
entry["git"] = toml_edit::value(git_url);
}
MarketplaceAddInput::LocalPath(path) => {
entry["path"] = toml_edit::value(path.display().to_string());
}
}
sources.push(entry);
std::fs::write(&config_path, doc.to_string())?;
println!("Added marketplace source: {name} ({identity})");
Ok(())
}
fn marketplace_remove(
sources: &[xai_grok_plugin_marketplace::MarketplaceSource],
url: &str,
) -> Result<()> {
let url = url.trim();
if url.is_empty() {
bail!("URL cannot be empty.");
}
let expanded = plugin::normalize_git_url(url);
let norm = url.trim_end_matches(".git");
let exp_norm = expanded.trim_end_matches(".git");
// Loaded local sources carry expanded paths, so expand `~`/relative inputs
// the same way `marketplace add` does before comparing.
let cwd = std::env::current_dir().unwrap_or_default();
let local_input = match plugin::classify_marketplace_add_input(url, &cwd) {
xai_grok_shell::plugin::MarketplaceAddInput::LocalPath(p) => Some(p),
_ => None,
};
let source = sources
.iter()
.find(|s| match &s.kind {
SourceKind::Git { url: u, .. } => {
let un = u.trim_end_matches(".git");
un == norm || un == exp_norm
}
SourceKind::Local { path } => {
path.display().to_string() == url || local_input.as_ref().is_some_and(|p| p == path)
}
})
.ok_or_else(|| anyhow::anyhow!("Marketplace source \"{url}\" not found."))?;
let identity = source_identity(source);
let uninstalled = plugin::uninstall_marketplace_source_plugins(&identity);
let config_path = xai_grok_config::grok_home().join("config.toml");
let mut removed_from_config = false;
if let Ok(content) = std::fs::read_to_string(&config_path)
&& let Some(new) = plugin::remove_toml_marketplace_block(&content, &identity)
{
if let Err(e) = std::fs::write(&config_path, new) {
tracing::warn!("failed to write config.toml: {e}");
} else {
removed_from_config = true;
}
}
// Fallback: settings.json / known_marketplaces.json.
if !removed_from_config && !plugin::try_remove_source_from_json_files(&identity) {
eprintln!(
"Warning: source was found but could not be removed from config files.\n\
It may be defined in a managed or read-only settings file."
);
}
if uninstalled.is_empty() {
println!("Removed marketplace source: {url}");
} else {
println!(
"Removed marketplace source and uninstalled {} plugin(s): {}",
uninstalled.len(),
uninstalled.join(", "),
);
}
Ok(())
}
fn marketplace_update(
sources: &[xai_grok_plugin_marketplace::MarketplaceSource],
name: Option<&str>,
) -> Result<()> {
marketplace_update_with_cache_root(
sources,
name,
&xai_grok_plugin_marketplace::git::default_cache_root(),
)
}
fn marketplace_update_with_cache_root(
sources: &[xai_grok_plugin_marketplace::MarketplaceSource],
name: Option<&str>,
cache_root: &Path,
) -> Result<()> {
let mut refreshed = 0;
let mut errors = Vec::new();
let mut name_matched = false;
for source in sources {
if let Some(filter) = name {
if source.name != filter {
continue;
}
name_matched = true;
}
if let SourceKind::Git { url, branch } = &source.kind {
match xai_grok_plugin_marketplace::git::force_sync_source_cache(
url,
branch.as_deref(),
cache_root,
) {
Ok(_) => {
println!(" {}: synced", source.name);
refreshed += 1;
}
Err(e) => errors.push(format!("{}: {e}", source.name)),
}
}
}
if refreshed == 0 && errors.is_empty() {
if let Some(filter) = name {
if name_matched {
// Source exists but is local — nothing to sync.
println!("Source \"{filter}\" is local — nothing to sync.");
} else {
bail!("Marketplace source \"{filter}\" not found.");
}
} else {
println!("No marketplace sources configured.");
}
} else if errors.is_empty() {
println!("Refreshed {refreshed} source(s).");
} else {
eprintln!(
"Refreshed {refreshed} source(s) with {} error(s): {}",
errors.len(),
errors.join("; "),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use xai_grok_plugin_marketplace::MarketplaceSource;
#[test]
fn trust_prompt_marketplace_has_no_error_framing() {
let msg = trust_prompt(
"\"sentry\" from marketplace \"xAI Official\"",
"sentry@xai-org/plugin-marketplace",
);
assert!(
msg.starts_with(
"Installing \"sentry\" from marketplace \"xAI Official\" requires confirmation."
),
"{msg}"
);
assert!(msg.contains("hooks, MCP servers, and skills"));
assert!(msg.contains(
"To proceed, re-run with --trust:\n grok plugin install sentry@xai-org/plugin-marketplace --trust"
));
assert!(!msg.contains("Error"));
assert!(!msg.contains("Failed"));
assert!(!msg.contains("Plugin source:"));
}
#[test]
fn trust_prompt_git_and_local_subjects() {
let git = trust_prompt("from git repo https://github.com/u/r", "u/r");
assert!(
git.starts_with(
"Installing from git repo https://github.com/u/r requires confirmation."
),
"{git}"
);
assert!(git.ends_with(" grok plugin install u/r --trust"), "{git}");
let local = trust_prompt("from directory /tmp/p", "./p");
assert!(
local.starts_with("Installing from directory /tmp/p requires confirmation."),
"{local}"
);
}
#[test]
fn marketplace_update_force_syncs_fresh_git_cache() {
if !git_available() {
eprintln!("skipping git-dependent test: git binary not available");
return;
}
let remote = tempfile::tempdir().unwrap();
init_remote_repo(remote.path());
let cache_root = tempfile::tempdir().unwrap();
let url = remote.path().to_string_lossy().to_string();
let source = MarketplaceSource {
name: "test-marketplace".into(),
kind: SourceKind::Git {
url: url.clone(),
branch: Some("main".into()),
},
};
let cache_dir = xai_grok_plugin_marketplace::git::sync_source_cache(
&url,
Some("main"),
cache_root.path(),
)
.unwrap();
let first_head = current_head(&cache_dir);
add_commit(remote.path(), "second.txt", "second");
marketplace_update_with_cache_root(&[source], None, cache_root.path()).unwrap();
assert_ne!(current_head(&cache_dir), first_head);
}
fn init_remote_repo(path: &Path) {
run_git(path, &["init", "--initial-branch", "main"]);
run_git(path, &["config", "user.email", "test@example.com"]);
run_git(path, &["config", "user.name", "Test User"]);
add_commit(path, "file.txt", "initial");
}
fn add_commit(repo: &Path, file: &str, contents: &str) {
std::fs::write(repo.join(file), contents).unwrap();
run_git(repo, &["add", file]);
run_git(repo, &["commit", "-m", file]);
}
fn current_head(repo: &Path) -> String {
let output = xai_grok_plugin_marketplace::git::git_command()
.current_dir(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn git_available() -> bool {
let git_bin = std::env::var("GIT_BIN_PATH").unwrap_or_else(|_| "git".to_string());
std::process::Command::new(git_bin)
.arg("--version")
.stdin(std::process::Stdio::null())
.output()
.is_ok_and(|output| output.status.success())
}
fn run_git(dir: &Path, args: &[&str]) {
let git_bin = std::env::var("GIT_BIN_PATH").unwrap_or_else(|_| "git".to_string());
let output = std::process::Command::new(git_bin)
.current_dir(dir)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "")
.env("GIT_LFS_SKIP_SMUDGE", "1")
.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes")
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
}