2026-07-16 06:46:02 +01:00
|
|
|
//! `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(®istry));
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-07-20 18:06:59 +01:00
|
|
|
MarketplaceAddInput::LocalPath(path) => sources.iter().any(|s| {
|
|
|
|
|
matches!(&s.kind, SourceKind::Local { path: p }
|
|
|
|
|
if p == path)
|
|
|
|
|
}),
|
2026-07-16 06:46:02 +01:00
|
|
|
};
|
|
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|