M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
//! `kigi plugin` CLI subcommand — manage installed plugins.
|
||||
//!
|
||||
//! 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 `kigi_shell::plugin` (shared orchestration) and
|
||||
//! lower crates (`kigi-agent`). This module is a thin CLI wrapper: parse args,
|
||||
//! call ops, and format output.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::Subcommand;
|
||||
use serde::Serialize;
|
||||
|
||||
use kigi_agent::plugins::install_registry::{InstallKind, InstallRegistry};
|
||||
use kigi_agent::plugins::manifest::{ManifestLoadResult, PluginManifest, load_manifest};
|
||||
use kigi_shell::plugin::{self, RepoUpdateOutcome, UninstallError};
|
||||
|
||||
// ── JSON output types ───────────────────────────────────────────────
|
||||
|
||||
/// Typed entry for `kigi plugin list --json`. The `status` field is a stable
|
||||
/// discriminator for machine consumers.
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
enum PluginEntry {
|
||||
Installed {
|
||||
name: String,
|
||||
repo_key: String,
|
||||
version: Option<String>,
|
||||
path: PathBuf,
|
||||
source: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
},
|
||||
/// 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 `kigi 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,
|
||||
},
|
||||
}
|
||||
|
||||
// ── 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 } => cmd_list(json),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin subcommands ──────────────────────────────────────────────
|
||||
|
||||
fn cmd_list(json: bool) -> Result<()> {
|
||||
let registry = InstallRegistry::load();
|
||||
let repos = registry.list();
|
||||
|
||||
if json {
|
||||
let entries = installed_plugins(&repos);
|
||||
println!("{}", serde_json::to_string_pretty(&entries)?);
|
||||
} else if repos.is_empty() {
|
||||
println!("No plugins installed. Run `kigi plugin install --help` to get started.");
|
||||
} else {
|
||||
for (repo_key, repo) in &repos {
|
||||
let names: Vec<&str> = repo.plugins.keys().map(|s| s.as_str()).collect();
|
||||
println!(
|
||||
" {repo_key}: {} [{}]",
|
||||
names.join(", "),
|
||||
kind_label(&repo.kind)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn installed_plugins(
|
||||
repos: &[(&str, &kigi_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(),
|
||||
};
|
||||
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(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cmd_install(source: &str, trust: bool) -> Result<()> {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
|
||||
if !trust {
|
||||
use kigi_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}");
|
||||
}
|
||||
println!(
|
||||
"Installed {} plugin(s) from {source}: {}",
|
||||
outcome.plugin_names.len(),
|
||||
outcome.plugin_names.join(", "),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => bail!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_uninstall(name: &str, confirm: bool, keep_data: bool) -> Result<()> {
|
||||
match plugin::uninstall_plugin(name, confirm, keep_data) {
|
||||
Ok(outcome) => {
|
||||
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 `kigi plugin list` to see installed plugins."
|
||||
);
|
||||
}
|
||||
if let Err(e) = kigi_shell::config::remove_disabled_plugin(name) {
|
||||
tracing::warn!("failed to remove from disabled list: {e}");
|
||||
}
|
||||
kigi_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 `kigi plugin list` to see installed plugins."
|
||||
);
|
||||
}
|
||||
if let Err(e) = kigi_shell::config::remove_enabled_plugin(name) {
|
||||
tracing::warn!("failed to remove from enabled list: {e}");
|
||||
}
|
||||
kigi_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 `kigi plugin list` to see installed plugins."
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("{repo_key}");
|
||||
println!(" path: {}", repo.path.display());
|
||||
println!(" kind: {}", 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 `kigi 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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user