Files
Kigi-CLI/crates/codegen/kigi-shell/src/plugin.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

460 lines
15 KiB
Rust

//! Shared plugin lifecycle operations (output-agnostic).
//!
//! Called by the CLI (`plugin_cmd.rs`). The in-session slash commands
//! (`acp_session.rs`) currently inline similar logic and should migrate here.
//!
//! Callers own output formatting.
use std::path::{Path, PathBuf};
use kigi_agent::plugins::discovery::PluginScope;
use kigi_agent::plugins::git_install::{self, UpdateStatus};
use kigi_agent::plugins::install_registry::{
InstallError, InstallKind, InstallRegistry, InstalledRepo,
};
// ── Helpers (internal) ──────────────────────────────────────────────
fn save_registry_or_warn(registry: &InstallRegistry) {
if let Err(e) = registry.save() {
tracing::warn!("failed to save install registry: {e}");
}
}
// ── Install ─────────────────────────────────────────────────────────
pub struct InstallOutcome {
pub repo_key: String,
pub plugin_names: Vec<String>,
pub warnings: Vec<String>,
/// Whether the source was a local path (vs git).
pub is_local: bool,
}
/// Classify an install source as local (filesystem) vs git (remote) without
/// installing — used to label the install kind on the failure path, where no
/// [`InstallOutcome`] is available.
pub fn install_source_is_local(source: &str, cwd: &Path) -> bool {
matches!(
git_install::parse_install_source(source, cwd),
git_install::InstallSource::Local { .. }
)
}
/// Parse, clone/symlink, register, and enable a plugin.
pub fn install_plugin(source: &str, cwd: &Path) -> Result<InstallOutcome, InstallError> {
let install_source = git_install::parse_install_source(source, cwd);
let is_local = matches!(install_source, git_install::InstallSource::Local { .. });
let mut registry = InstallRegistry::load();
let result = git_install::install_from_source(&install_source, &registry)?;
let repo = git_install::build_installed_repo(&result, &install_source);
registry.insert(result.repo_key.clone(), repo);
save_registry_or_warn(&registry);
let (plugin_names, post_warnings) = crate::config::post_install_plugin(&result.repo_key);
Ok(InstallOutcome {
repo_key: result.repo_key,
plugin_names,
warnings: post_warnings,
is_local,
})
}
// ── Uninstall ───────────────────────────────────────────────────────
pub struct UninstallOutcome {
pub repo_key: String,
pub removed_plugins: Vec<String>,
}
pub enum UninstallError {
NotFound {
name: String,
},
NeedsConfirm {
name: String,
repo_key: String,
other_plugins: Vec<String>,
total: usize,
},
}
impl std::fmt::Display for UninstallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound { name } => {
write!(
f,
"Plugin \"{name}\" not found.\n\
Run `grok plugin list` to see installed plugins."
)
}
Self::NeedsConfirm {
name,
repo_key,
other_plugins,
total,
} => {
writeln!(
f,
"Plugin \"{name}\" belongs to repo \"{repo_key}\" which also contains:"
)?;
for p in other_plugins {
writeln!(f, " - {p}")?;
}
writeln!(f)?;
write!(f, "Uninstalling will remove all {total} plugin(s).")
}
}
}
}
/// Find, remove, clean up, and deregister a plugin.
/// When `keep_data` is true, `~/.kigi/plugin-data/<id>/` is preserved.
pub fn uninstall_plugin(
name: &str,
confirm: bool,
keep_data: bool,
) -> Result<UninstallOutcome, UninstallError> {
let mut registry = InstallRegistry::load();
let (repo_key, repo) = match registry.find_plugin(name) {
Some((k, r, _)) => (k.to_string(), r.clone()),
None => {
return Err(UninstallError::NotFound {
name: name.to_string(),
});
}
};
let removed_plugins: Vec<String> = repo.plugins.keys().cloned().collect();
if removed_plugins.len() > 1 && !confirm {
let others: Vec<_> = removed_plugins
.iter()
.filter(|p| p.as_str() != name)
.cloned()
.collect();
return Err(UninstallError::NeedsConfirm {
name: name.to_string(),
repo_key,
other_plugins: others,
total: removed_plugins.len(),
});
}
if let Err(e) = git_install::remove_repo_path(&repo.path) {
tracing::warn!("failed to remove repo path: {e}");
}
if !keep_data {
// Plugins under $HOME are user-scope; everything else is config-path scope.
let scope = match dirs::home_dir() {
Some(home) if repo.path.starts_with(&home) => PluginScope::User,
_ => PluginScope::ConfigPath,
};
git_install::cleanup_plugin_data(&repo, scope);
}
registry.remove(&repo_key);
save_registry_or_warn(&registry);
Ok(UninstallOutcome {
repo_key,
removed_plugins,
})
}
// ── Update ──────────────────────────────────────────────────────────
pub enum RepoUpdateOutcome {
Updated {
repo_key: String,
old_commit: Option<String>,
new_commit: Option<String>,
},
AlreadyUpToDate {
repo_key: String,
},
Pinned {
repo_key: String,
ref_name: String,
},
LiveLocal {
repo_key: String,
},
Failed {
repo_key: String,
error: String,
},
}
pub enum UpdateError {
NotFound { name: String },
}
pub enum PluginUpdateSelector {
PluginName(String),
RepoKey(String),
}
pub fn repo_update_requires_reload(outcome: &RepoUpdateOutcome) -> bool {
matches!(outcome, RepoUpdateOutcome::Updated { .. })
}
impl std::fmt::Display for UpdateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound { name } => {
write!(
f,
"Plugin \"{name}\" not found.\n\
Run `grok plugin list` to see installed plugins."
)
}
}
}
}
/// Apply an update result to the registry entry.
fn apply_update_to_registry(
registry: &mut InstallRegistry,
repo_key: &str,
result: &git_install::UpdateResult,
) {
let Some(entry) = registry.get_repo_mut(repo_key) else {
return;
};
if let InstallKind::Git { ref mut commit, .. } = entry.kind {
*commit = result.new_commit.clone().unwrap_or_default();
}
entry.updated_at = chrono::Utc::now().to_rfc3339();
entry.plugins = git_install::repo_plugin_map(&result.plugins);
}
/// Update one or all installed plugins. Saves the registry once at the end.
pub fn update_plugins(name: Option<&str>) -> Result<Vec<RepoUpdateOutcome>, UpdateError> {
update_plugins_by_selector(name.map(|name| PluginUpdateSelector::PluginName(name.to_string())))
}
pub fn update_plugins_by_selector(
selector: Option<PluginUpdateSelector>,
) -> Result<Vec<RepoUpdateOutcome>, UpdateError> {
let mut registry = InstallRegistry::load();
let repos_to_update: Vec<(String, InstalledRepo)> = match selector {
Some(PluginUpdateSelector::PluginName(plugin_name)) => {
match registry.find_plugin(&plugin_name) {
Some((key, repo, _)) => vec![(key.to_string(), repo.clone())],
None => {
return Err(UpdateError::NotFound {
name: plugin_name.to_string(),
});
}
}
}
Some(PluginUpdateSelector::RepoKey(repo_key)) => match registry.get_repo(&repo_key) {
Some(repo) => vec![(repo_key.to_string(), repo.clone())],
None => {
return Err(UpdateError::NotFound {
name: repo_key.to_string(),
});
}
},
None => registry
.list()
.into_iter()
.map(|(k, r)| (k.to_string(), r.clone()))
.collect(),
};
let mut outcomes = Vec::with_capacity(repos_to_update.len());
for (repo_key, repo) in &repos_to_update {
let outcome = match git_install::update_repo(repo_key, repo) {
Ok(UpdateStatus::Updated(result)) if result.changed => {
apply_update_to_registry(&mut registry, repo_key, &result);
RepoUpdateOutcome::Updated {
repo_key: repo_key.clone(),
old_commit: result.old_commit,
new_commit: result.new_commit,
}
}
Ok(UpdateStatus::Updated(_)) => RepoUpdateOutcome::AlreadyUpToDate {
repo_key: repo_key.clone(),
},
Ok(UpdateStatus::Pinned { ref_name }) => RepoUpdateOutcome::Pinned {
repo_key: repo_key.clone(),
ref_name,
},
Ok(UpdateStatus::LiveLocal) => RepoUpdateOutcome::LiveLocal {
repo_key: repo_key.clone(),
},
Err(e) => RepoUpdateOutcome::Failed {
repo_key: repo_key.clone(),
error: e.to_string(),
},
};
outcomes.push(outcome);
}
save_registry_or_warn(&registry);
Ok(outcomes)
}
// ── Source helpers ──────────────────────────────────────────────────
/// Expand GitHub shorthand (user/repo) to `https://github.com/user/repo.git`.
pub fn normalize_git_url(input: &str) -> String {
if !input.contains("://") && !input.contains("git@") {
format!("https://github.com/{}.git", input.trim_end_matches(".git"))
} else {
input.to_string()
}
}
/// Derive a display name from the last path segment of a URL.
pub fn name_from_url(url: &str) -> String {
let name = url
.trim_end_matches('/')
.trim_end_matches(".git")
.rsplit('/')
.next()
.unwrap_or("plugin");
if name.is_empty() {
"plugin".to_string()
} else {
name.to_string()
}
}
/// Derive a display name from the last component of a local path.
pub fn name_from_path(path: &Path) -> String {
path.file_name()
.and_then(|s| s.to_str())
.map(str::to_string)
.unwrap_or_else(|| "plugin".to_string())
}
/// Classify an install error into a stable category. Strings match `acp_session.rs` exactly.
pub fn classify_install_error(err: &InstallError) -> String {
match err {
InstallError::AlreadyInstalled { .. } => "already_installed",
InstallError::Io { .. } => "io",
InstallError::Json { .. } => "json",
InstallError::PluginNotFound { .. } => "not_found",
InstallError::ShaMismatch { .. } => "sha_mismatch",
InstallError::InstallFailed { .. } => "install_failed",
}
.to_string()
}
// ── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn normalize_github_shorthand() {
assert_eq!(
normalize_git_url("user/repo"),
"https://github.com/user/repo.git"
);
// .git suffix not doubled
assert_eq!(
normalize_git_url("user/repo.git"),
"https://github.com/user/repo.git"
);
}
#[test]
fn name_from_path_uses_last_component() {
assert_eq!(name_from_path(Path::new("/a/b/my-plugins")), "my-plugins");
assert_eq!(name_from_path(Path::new("/a/b/my-plugins/")), "my-plugins");
assert_eq!(name_from_path(Path::new("/")), "plugin");
}
#[test]
fn name_from_url_extracts_last_segment() {
assert_eq!(
name_from_url("https://github.com/org/my-marketplace.git"),
"my-marketplace"
);
}
#[test]
fn name_from_url_edge_cases() {
assert_eq!(name_from_url("https://github.com/org/repo/"), "repo"); // trailing slash bug fix
assert_eq!(name_from_url(""), "plugin"); // empty fallback
}
#[test]
fn classify_error_strings_match_canonical() {
// Must match acp_session.rs::classify_install_error exactly — prevents category drift.
assert_eq!(
classify_install_error(&InstallError::AlreadyInstalled { key: "k".into() }),
"already_installed"
);
assert_eq!(
classify_install_error(&InstallError::Io {
path: "p".into(),
source: std::io::Error::other("x")
}),
"io"
);
assert_eq!(
classify_install_error(&InstallError::Json { detail: "x".into() }),
"json"
);
assert_eq!(
classify_install_error(&InstallError::PluginNotFound { name: "x".into() }),
"not_found"
);
assert_eq!(
classify_install_error(&InstallError::ShaMismatch {
expected: "a".into(),
actual: "b".into()
}),
"sha_mismatch"
);
assert_eq!(
classify_install_error(&InstallError::InstallFailed { detail: "x".into() }),
"install_failed"
);
}
#[test]
fn update_requires_reload_for_changed_repo_updates_only() {
assert!(repo_update_requires_reload(&RepoUpdateOutcome::Updated {
repo_key: "git".into(),
old_commit: Some("a".into()),
new_commit: Some("b".into()),
}));
assert!(!repo_update_requires_reload(
&RepoUpdateOutcome::LiveLocal {
repo_key: "local".into(),
}
));
}
#[test]
fn local_update_remains_noop() {
let repo = InstalledRepo {
kind: InstallKind::Local {
source_path: PathBuf::from("/tmp/plugin"),
subdir: None,
},
installed_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
path: PathBuf::from("/tmp/installed"),
plugins: HashMap::new(),
};
let status = git_install::update_repo("local", &repo).unwrap();
assert!(matches!(status, UpdateStatus::LiveLocal));
}
}