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).
631 lines
21 KiB
Rust
631 lines
21 KiB
Rust
use agent_client_protocol as acp;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::util::config as cli_config;
|
|
use kigi_agent::prompt::skills::{CompatConfig, SkillInfo, SkillsConfig, list_skills_with_plugins};
|
|
|
|
use super::ExtResult;
|
|
|
|
/// Generic params for methods that only need an optional `cwd`.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CwdParams {
|
|
#[serde(default)]
|
|
cwd: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsAddRequest {
|
|
/// Path to add (directory or SKILL.md file). Supports `~` expansion.
|
|
pub path: String,
|
|
/// Working directory for skill discovery context.
|
|
#[serde(default)]
|
|
pub cwd: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsAddResponse {
|
|
/// Number of skills discovered at the added path.
|
|
pub added_count: usize,
|
|
/// Total number of skills loaded across all sources.
|
|
pub total: usize,
|
|
/// The path that was added to config.
|
|
pub path: String,
|
|
/// Full updated skill list after reload.
|
|
pub skills: Vec<SkillInfo>,
|
|
/// Human-readable message.
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsRemoveRequest {
|
|
/// Path to remove from config paths.
|
|
pub path: String,
|
|
/// Working directory for skill discovery context.
|
|
#[serde(default)]
|
|
pub cwd: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsRemoveResponse {
|
|
/// The path that was removed.
|
|
pub path: String,
|
|
/// Full updated skill list after reload.
|
|
pub skills: Vec<SkillInfo>,
|
|
/// Human-readable message.
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsResetResponse {
|
|
/// Full updated skill list after reload.
|
|
pub skills: Vec<SkillInfo>,
|
|
/// Human-readable message.
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsToggleRequest {
|
|
/// Skill name to toggle.
|
|
pub name: String,
|
|
/// Whether to enable (`true`) or disable (`false`) the skill.
|
|
pub enabled: bool,
|
|
/// Working directory for skill discovery context.
|
|
#[serde(default)]
|
|
pub cwd: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsListRequest {
|
|
/// Working directory for skill discovery context.
|
|
pub cwd: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsListResponse {
|
|
/// All discovered skills.
|
|
pub skills: Vec<SkillInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillsConfigResponse {
|
|
/// Configured paths from `[skills].paths`.
|
|
pub paths: Vec<String>,
|
|
/// Ignored paths from `[skills].ignore`.
|
|
pub ignore: Vec<String>,
|
|
/// Total loaded skill count.
|
|
pub total_skills: usize,
|
|
/// Human-readable summary.
|
|
pub message: String,
|
|
/// Full updated skill list.
|
|
pub skills: Vec<SkillInfo>,
|
|
}
|
|
|
|
/// Reload skills using the current config for the given working directory.
|
|
#[tracing::instrument(skip_all, fields(cwd))]
|
|
async fn reload_skills(
|
|
cwd: &str,
|
|
plugin_registry: Option<&kigi_agent::plugins::PluginRegistry>,
|
|
compat: CompatConfig,
|
|
) -> Vec<SkillInfo> {
|
|
let config = cli_config::load_config().await.skills;
|
|
let discovery = list_skills_with_plugins(Some(cwd), &config, plugin_registry, compat);
|
|
match tokio::time::timeout(std::time::Duration::from_secs(5), discovery).await {
|
|
Ok(skills) => skills,
|
|
Err(_) => {
|
|
tracing::warn!("Skills reload timed out");
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Count how many skills have paths starting with the given prefix.
|
|
fn count_skills_from(skills: &[SkillInfo], dir: &std::path::Path) -> usize {
|
|
let prefix = dir.to_str().unwrap_or("");
|
|
skills.iter().filter(|s| s.path.starts_with(prefix)).count()
|
|
}
|
|
|
|
/// Resolve a skill path to an absolute path.
|
|
///
|
|
/// Handles `~` expansion and relative path resolution against `cwd`.
|
|
/// Falls back to the original string if canonicalization fails.
|
|
fn resolve_skill_path(raw: &str, cwd: &str) -> String {
|
|
use std::path::PathBuf;
|
|
|
|
// Expand ~ to $HOME
|
|
let expanded = if let Some(rest) = raw.strip_prefix("~/") {
|
|
std::env::var_os("HOME")
|
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
|
.map(|home| PathBuf::from(home).join(rest))
|
|
.unwrap_or_else(|| PathBuf::from(raw))
|
|
} else if raw == "~" {
|
|
std::env::var_os("HOME")
|
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from(raw))
|
|
} else {
|
|
PathBuf::from(raw)
|
|
};
|
|
|
|
// If already absolute, canonicalize to resolve `..` etc.
|
|
// If relative, join with cwd first.
|
|
let absolute = if expanded.is_absolute() {
|
|
expanded
|
|
} else {
|
|
PathBuf::from(cwd).join(&expanded)
|
|
};
|
|
|
|
// canonicalize resolves symlinks and `..` — fall back to the joined path if it fails
|
|
// (e.g. path doesn't exist yet)
|
|
dunce::canonicalize(&absolute)
|
|
.unwrap_or(absolute)
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
/// Collect auto-discovered skill source directories and their counts.
|
|
fn discover_auto_sources(cwd: &str, skills: &[SkillInfo]) -> Vec<(String, usize)> {
|
|
let cwd_path = std::path::PathBuf::from(cwd);
|
|
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
|
|
let git_root = git2::Repository::discover(&cwd_path)
|
|
.ok()
|
|
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()));
|
|
|
|
// Once the user has imported, stop scanning hardcoded
|
|
// .claude/skills/ paths. Equivalent locations should be opted in via
|
|
// [paths] extra_skill_dirs in config.toml (written by /import-claude).
|
|
let imported = crate::claude_import::is_claude_import_marked();
|
|
let local_dir_names: &[&str] = if imported {
|
|
&[".kigi", ".agents"]
|
|
} else {
|
|
&[".kigi", ".agents", ".claude"]
|
|
};
|
|
|
|
let mut sources: Vec<(String, usize)> = Vec::new();
|
|
let subdirs = ["skills", "commands"];
|
|
|
|
let mut try_add_source = |dir: std::path::PathBuf, seen: Option<&[std::path::PathBuf]>| {
|
|
if dir.is_dir() && !seen.is_some_and(|s| s.contains(&dir)) {
|
|
let count = count_skills_from(skills, &dir);
|
|
if count > 0 {
|
|
sources.push((dir.to_string_lossy().to_string(), count));
|
|
}
|
|
}
|
|
};
|
|
|
|
let mut local_dirs: Vec<std::path::PathBuf> = Vec::new();
|
|
for dir_name in local_dir_names {
|
|
for subdir in &subdirs {
|
|
let dir = cwd_path.join(dir_name).join(subdir);
|
|
try_add_source(dir.clone(), None);
|
|
local_dirs.push(dir);
|
|
}
|
|
}
|
|
|
|
if let Some(ref root) = git_root {
|
|
for dir_name in local_dir_names {
|
|
for subdir in &subdirs {
|
|
try_add_source(root.join(dir_name).join(subdir), Some(&local_dirs));
|
|
}
|
|
}
|
|
}
|
|
|
|
for subdir in &subdirs {
|
|
try_add_source(kigi_home.join(subdir), None);
|
|
}
|
|
|
|
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
|
|
if let Some(ref h) = home {
|
|
let home_path = std::path::PathBuf::from(h);
|
|
for subdir in &subdirs {
|
|
try_add_source(home_path.join(".agents").join(subdir), None);
|
|
}
|
|
if !imported {
|
|
for subdir in &subdirs {
|
|
try_add_source(home_path.join(".claude").join(subdir), None);
|
|
}
|
|
}
|
|
}
|
|
|
|
// [paths] extra_skill_dirs from config.toml. These supplement the built-in
|
|
// scan locations. Used both standalone and as the migration target after
|
|
// /import-claude when the runtime .claude/skills/ scan is disabled.
|
|
for dir in extra_skill_dirs_from_config() {
|
|
let path = crate::claude_import::expand_home(&dir);
|
|
if path.is_dir()
|
|
&& !sources
|
|
.iter()
|
|
.any(|(s, _)| s.as_str() == path.to_string_lossy().as_ref())
|
|
{
|
|
sources.push((
|
|
path.to_string_lossy().to_string(),
|
|
count_skills_from(skills, &path),
|
|
));
|
|
}
|
|
}
|
|
|
|
sources
|
|
}
|
|
|
|
/// Read `[paths] extra_skill_dirs` from the effective config. Returns empty
|
|
/// on any read/parse failure so misconfiguration never breaks listing.
|
|
fn extra_skill_dirs_from_config() -> Vec<String> {
|
|
let Ok(root) = crate::config::load_effective_config() else {
|
|
return Vec::new();
|
|
};
|
|
root.get("paths")
|
|
.and_then(|v| v.get("extra_skill_dirs"))
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
|
pub async fn handle(
|
|
args: &acp::ExtRequest,
|
|
plugin_registry: Option<&kigi_agent::plugins::PluginRegistry>,
|
|
compat: CompatConfig,
|
|
) -> ExtResult {
|
|
match args.method.as_ref() {
|
|
"x.ai/skills/add" => {
|
|
let req: SkillsAddRequest = serde_json::from_str(args.params.get())?;
|
|
let cwd = req.cwd.as_deref().unwrap_or(".");
|
|
|
|
// Resolve to absolute path so config entries work from any cwd.
|
|
let resolved = resolve_skill_path(&req.path, cwd);
|
|
|
|
let p = resolved.clone();
|
|
if let Err(e) = cli_config::update_config(|cfg| {
|
|
cfg.skills.ignore.retain(|i| {
|
|
!(i == &p || p.starts_with(i.as_str()) || i.starts_with(p.as_str()))
|
|
});
|
|
if !cfg.skills.paths.contains(&p) {
|
|
cfg.skills.paths.push(p);
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
return super::to_ext_response(Err::<SkillsAddResponse, _>(anyhow::anyhow!(
|
|
"Failed to save config: {e}"
|
|
)));
|
|
}
|
|
|
|
let skills = reload_skills(cwd, plugin_registry, compat).await;
|
|
let added_count = skills
|
|
.iter()
|
|
.filter(|s| s.path.starts_with(&resolved))
|
|
.count();
|
|
let total = skills.len();
|
|
let message = format!(
|
|
"Added path {}. {} new skill{} found ({} total).",
|
|
resolved,
|
|
added_count,
|
|
if added_count == 1 { "" } else { "s" },
|
|
total,
|
|
);
|
|
|
|
super::to_ext_response(Ok(SkillsAddResponse {
|
|
added_count,
|
|
total,
|
|
path: resolved,
|
|
skills,
|
|
message,
|
|
}))
|
|
}
|
|
|
|
"x.ai/skills/remove" => {
|
|
let req: SkillsRemoveRequest = serde_json::from_str(args.params.get())?;
|
|
let cwd = req.cwd.as_deref().unwrap_or(".");
|
|
|
|
// Resolve so relative/tilde paths match what was saved by add.
|
|
let resolved = resolve_skill_path(&req.path, cwd);
|
|
|
|
let p = resolved.clone();
|
|
if let Err(e) = cli_config::update_config(|cfg| {
|
|
cfg.skills.paths.retain(|i| i != &p);
|
|
})
|
|
.await
|
|
{
|
|
return super::to_ext_response(Err::<SkillsRemoveResponse, _>(anyhow::anyhow!(
|
|
"Failed to save config: {e}"
|
|
)));
|
|
}
|
|
|
|
let skills = reload_skills(cwd, plugin_registry, compat).await;
|
|
let total = skills.len();
|
|
let message = format!(
|
|
"Removed path {}. {} skill{} remaining.",
|
|
resolved,
|
|
total,
|
|
if total == 1 { "" } else { "s" },
|
|
);
|
|
|
|
super::to_ext_response(Ok(SkillsRemoveResponse {
|
|
path: resolved,
|
|
skills,
|
|
message,
|
|
}))
|
|
}
|
|
|
|
"x.ai/skills/reset" => {
|
|
let params: CwdParams =
|
|
serde_json::from_str(args.params.get()).unwrap_or(CwdParams { cwd: None });
|
|
let cwd = params.cwd.as_deref().unwrap_or(".");
|
|
|
|
if let Err(e) = cli_config::update_config(|cfg| {
|
|
cfg.skills = SkillsConfig::default();
|
|
})
|
|
.await
|
|
{
|
|
return super::to_ext_response(Err::<SkillsResetResponse, _>(anyhow::anyhow!(
|
|
"Failed to save config: {e}"
|
|
)));
|
|
}
|
|
|
|
let skills = reload_skills(cwd, plugin_registry, compat).await;
|
|
let message = "Custom skills config reset".to_string();
|
|
|
|
super::to_ext_response(Ok(SkillsResetResponse { skills, message }))
|
|
}
|
|
|
|
"x.ai/skills/list" => {
|
|
let req: SkillsListRequest = serde_json::from_str(args.params.get())?;
|
|
let skills = reload_skills(&req.cwd, plugin_registry, compat).await;
|
|
super::to_ext_response(Ok(SkillsListResponse { skills }))
|
|
}
|
|
|
|
"x.ai/skills/config" => {
|
|
let params: CwdParams =
|
|
serde_json::from_str(args.params.get()).unwrap_or(CwdParams { cwd: None });
|
|
let cwd = params.cwd.as_deref().unwrap_or(".");
|
|
|
|
let config = cli_config::load_config().await.skills;
|
|
let paths = config.paths.clone();
|
|
let ignore = config.ignore.clone();
|
|
|
|
let skills = reload_skills(cwd, plugin_registry, compat).await;
|
|
let total_skills = skills.len();
|
|
|
|
let auto_sources = discover_auto_sources(cwd, &skills);
|
|
|
|
let mut msg = String::new();
|
|
|
|
msg.push_str("Skill discovery sources:\n");
|
|
for (source, count) in &auto_sources {
|
|
msg.push_str(&format!(
|
|
" • {} ({} skill{})\n",
|
|
source,
|
|
count,
|
|
if *count == 1 { "" } else { "s" }
|
|
));
|
|
}
|
|
if auto_sources.is_empty() {
|
|
msg.push_str(" (no auto-discovered directories found)\n");
|
|
}
|
|
|
|
if !paths.is_empty() {
|
|
msg.push_str("\nCustom paths:\n");
|
|
for p in &paths {
|
|
let count = skills
|
|
.iter()
|
|
.filter(|s| s.path.starts_with(p.as_str()))
|
|
.count();
|
|
msg.push_str(&format!(
|
|
" • {} ({} skill{})\n",
|
|
p,
|
|
count,
|
|
if count == 1 { "" } else { "s" }
|
|
));
|
|
}
|
|
}
|
|
|
|
if !ignore.is_empty() {
|
|
msg.push_str("\nIgnored:\n");
|
|
for p in &ignore {
|
|
msg.push_str(&format!(" • {}\n", p));
|
|
}
|
|
}
|
|
|
|
msg.push_str(&format!("\nTotal skills loaded: {}", total_skills));
|
|
|
|
super::to_ext_response(Ok(SkillsConfigResponse {
|
|
paths,
|
|
ignore,
|
|
total_skills,
|
|
message: msg,
|
|
skills,
|
|
}))
|
|
}
|
|
|
|
"x.ai/skills/toggle" => {
|
|
let req: SkillsToggleRequest = serde_json::from_str(args.params.get())?;
|
|
let cwd = req.cwd.as_deref().unwrap_or(".");
|
|
|
|
// Validate the skill name exists before modifying config.
|
|
let current_skills = reload_skills(cwd, plugin_registry, compat).await;
|
|
if !current_skills.iter().any(|s| s.name == req.name) {
|
|
return super::to_ext_response(Err::<SkillsListResponse, _>(anyhow::anyhow!(
|
|
"Skill '{}' not found",
|
|
req.name
|
|
)));
|
|
}
|
|
|
|
let name = req.name.clone();
|
|
let enabled = req.enabled;
|
|
if let Err(e) = cli_config::update_config(|cfg| {
|
|
if enabled {
|
|
cfg.skills.disabled.retain(|d| d != &name);
|
|
} else if !cfg.skills.disabled.contains(&name) {
|
|
cfg.skills.disabled.push(name.clone());
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
return super::to_ext_response(Err::<SkillsListResponse, _>(anyhow::anyhow!(
|
|
"Failed to save config: {e}"
|
|
)));
|
|
}
|
|
|
|
// Re-apply disabled marking against the already-loaded skills
|
|
// to reflect the config change without a second full discovery.
|
|
let config = cli_config::load_config().await.skills;
|
|
let disabled_set: std::collections::HashSet<&str> =
|
|
config.disabled.iter().map(|s| s.as_str()).collect();
|
|
let skills: Vec<SkillInfo> = current_skills
|
|
.into_iter()
|
|
.map(|mut s| {
|
|
s.enabled = !disabled_set.contains(s.name.as_str());
|
|
s
|
|
})
|
|
.collect();
|
|
super::to_ext_response(Ok(SkillsListResponse { skills }))
|
|
}
|
|
|
|
_ => Err(acp::Error::method_not_found()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_add_request_with_cwd() {
|
|
let json = r#"{"path": "/home/user/skills", "cwd": "/project"}"#;
|
|
let req: SkillsAddRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.path, "/home/user/skills");
|
|
assert_eq!(req.cwd, Some("/project".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_request_without_cwd() {
|
|
let json = r#"{"path": "~/my-skills"}"#;
|
|
let req: SkillsAddRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.path, "~/my-skills");
|
|
assert_eq!(req.cwd, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_request() {
|
|
let json = r#"{"path": "/home/user/skills", "cwd": "/project"}"#;
|
|
let req: SkillsRemoveRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.path, "/home/user/skills");
|
|
assert_eq!(req.cwd, Some("/project".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_request() {
|
|
let json = r#"{"cwd": "/project"}"#;
|
|
let req: SkillsListRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(req.cwd, "/project");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_response_camel_case() {
|
|
let resp = SkillsAddResponse {
|
|
added_count: 3,
|
|
total: 10,
|
|
path: "/test".to_string(),
|
|
skills: vec![],
|
|
message: "ok".to_string(),
|
|
};
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["addedCount"], 3);
|
|
assert_eq!(json["total"], 10);
|
|
assert_eq!(json["path"], "/test");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_absolute_path_unchanged() {
|
|
let resolved = resolve_skill_path("/absolute/path/to/skills", "/some/cwd");
|
|
// Canonicalize will fail (path doesn't exist), so we get the joined absolute path
|
|
assert_eq!(resolved, "/absolute/path/to/skills");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_relative_path_against_cwd() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let sub = tmp.path().join("sub");
|
|
std::fs::create_dir(&sub).unwrap();
|
|
|
|
let resolved = resolve_skill_path("sub", &tmp.path().to_string_lossy());
|
|
assert_eq!(
|
|
resolved,
|
|
dunce::canonicalize(&sub).unwrap().to_string_lossy()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_dotdot_path() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cwd = tmp.path().join("a").join("b");
|
|
std::fs::create_dir_all(&cwd).unwrap();
|
|
|
|
let resolved = resolve_skill_path("../..", &cwd.to_string_lossy());
|
|
assert_eq!(
|
|
resolved,
|
|
dunce::canonicalize(tmp.path()).unwrap().to_string_lossy()
|
|
);
|
|
}
|
|
|
|
/// Hermetic tilde expansion: pin HOME to a temp dir so remote sandboxes
|
|
/// (missing HOME, symlink-resolved homes, pre-existing ~/my-skills) cannot
|
|
/// make `starts_with($HOME)` fail spuriously. Serial because env mutation
|
|
/// is process-global.
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_resolve_tilde_path() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let home = tmp.path().to_path_buf();
|
|
let prev_home = std::env::var_os("HOME");
|
|
let prev_userprofile = std::env::var_os("USERPROFILE");
|
|
// SAFETY: serial test; restored in the same scope below.
|
|
unsafe {
|
|
std::env::set_var("HOME", &home);
|
|
std::env::remove_var("USERPROFILE");
|
|
}
|
|
let resolved = resolve_skill_path("~/my-skills", "/ignored");
|
|
match prev_home {
|
|
Some(v) => unsafe { std::env::set_var("HOME", v) },
|
|
None => unsafe { std::env::remove_var("HOME") },
|
|
}
|
|
match prev_userprofile {
|
|
Some(v) => unsafe { std::env::set_var("USERPROFILE", v) },
|
|
None => unsafe { std::env::remove_var("USERPROFILE") },
|
|
}
|
|
let expected = home.join("my-skills");
|
|
assert_eq!(
|
|
std::path::PathBuf::from(&resolved),
|
|
expected,
|
|
"resolved={resolved}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_response_camel_case() {
|
|
let resp = SkillsConfigResponse {
|
|
paths: vec!["/a".into()],
|
|
ignore: vec![],
|
|
total_skills: 5,
|
|
message: "ok".to_string(),
|
|
skills: vec![],
|
|
};
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["totalSkills"], 5);
|
|
assert!(json["paths"].is_array());
|
|
}
|
|
}
|