Files
BrowserSkill/crates/bsk-cli/src/skill_install/harness.rs
T
BB-fat d0bee1a09b feat(install-skill): add Kimi Code harness support (#65)
* feat(install-skill): add Kimi Code harness support

Add kimi-code to the install-skill harness list: installs the bundled SKILL.md into ~/.kimi/skills (Kimi Code CLI's brand-group user skills directory, highest priority per https://github.com/MoonshotAI/kimi-cli/blob/main/docs/en/customization/skills.md). Detection signals: ~/.kimi directory and kimi/kimi-cli on PATH. Aliases: kimi, kimi-code, kimi-cli, kimicode, kimi_cli.

* fix(install-skill): point kimi-code harness at ~/.kimi-code and honor KIMI_CODE_HOME

Kimi Code CLI's canonical repo is MoonshotAI/kimi-code: user-level skills live in $KIMI_CODE_HOME/skills (default ~/.kimi-code/skills), not kimi-cli's ~/.kimi/skills. Detection checks the kimi-code home dir (env-aware label) and the kimi binary on PATH. Serialize env-mutating kimi tests with a shared mutex to avoid a parallel-test race on KIMI_CODE_HOME.
2026-08-06 13:25:18 +08:00

578 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Agent harness identifiers, install paths, and local detection heuristics.
use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use serde::Serialize;
use super::SKILL_DIR_NAME;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessId {
Codex,
CodexInternal,
ClaudeCode,
ClaudeCodeInternal,
Cursor,
Openclaw,
OpenclawInternal,
Codebuddy,
Workbuddy,
PiAgent,
Hermes,
KimiCode,
}
impl HarnessId {
pub const ALL: &'static [HarnessId] = &[
HarnessId::Codex,
HarnessId::CodexInternal,
HarnessId::ClaudeCode,
HarnessId::ClaudeCodeInternal,
HarnessId::Cursor,
HarnessId::Openclaw,
HarnessId::OpenclawInternal,
HarnessId::Codebuddy,
HarnessId::Workbuddy,
HarnessId::PiAgent,
HarnessId::Hermes,
HarnessId::KimiCode,
];
pub fn index(self) -> usize {
Self::ALL.iter().position(|h| *h == self).unwrap_or(0)
}
pub fn cli_name(self) -> &'static str {
match self {
HarnessId::Codex => "codex",
HarnessId::CodexInternal => "codex-internal",
HarnessId::ClaudeCode => "claude-code",
HarnessId::ClaudeCodeInternal => "claudecode-internal",
HarnessId::Cursor => "cursor",
HarnessId::Openclaw => "openclaw",
HarnessId::OpenclawInternal => "openclaw-internal",
HarnessId::Codebuddy => "codebuddy",
HarnessId::Workbuddy => "workbuddy",
HarnessId::PiAgent => "pi",
HarnessId::Hermes => "hermes",
HarnessId::KimiCode => "kimi-code",
}
}
pub fn display_name(self) -> &'static str {
match self {
HarnessId::Codex => "Codex",
HarnessId::CodexInternal => "Codex (internal)",
HarnessId::ClaudeCode => "Claude Code",
HarnessId::ClaudeCodeInternal => "Claude Code (internal)",
HarnessId::Cursor => "Cursor",
HarnessId::Openclaw => "OpenClaw",
HarnessId::OpenclawInternal => "OpenClaw (internal)",
HarnessId::Codebuddy => "CodeBuddy",
HarnessId::Workbuddy => "WorkBuddy",
HarnessId::PiAgent => "Pi",
HarnessId::Hermes => "Hermes Agent",
HarnessId::KimiCode => "Kimi Code",
}
}
pub fn skills_dir_for_home(self, home: &Path) -> PathBuf {
match self {
HarnessId::Codex => home.join(".agents").join("skills"),
HarnessId::CodexInternal => home.join(".codex-internal").join("skills"),
HarnessId::ClaudeCode => home.join(".claude").join("skills"),
HarnessId::ClaudeCodeInternal => home.join(".claude-internal").join("skills"),
HarnessId::Cursor => home.join(".cursor").join("skills"),
HarnessId::Openclaw => home.join(".openclaw").join("skills"),
HarnessId::OpenclawInternal => home.join(".openclaw-internal").join("skills"),
HarnessId::Codebuddy => home.join(".codebuddy").join("skills"),
HarnessId::Workbuddy => home.join(".workbuddy").join("skills"),
HarnessId::PiAgent => home.join(".pi").join("agent").join("skills"),
HarnessId::Hermes => hermes_home_for_user_home(home).join("skills"),
// Kimi Code CLI 用户级 skills 目录:`$KIMI_CODE_HOME/skills`,
// 默认 ~/.kimi-code/skills,见
// https://github.com/MoonshotAI/kimi-code/blob/main/docs/en/customization/skills.md
HarnessId::KimiCode => kimi_code_home_for_user_home(home).join("skills"),
}
}
pub fn skill_dest_dir_for_home(self, home: &Path) -> PathBuf {
self.skills_dir_for_home(home).join(SKILL_DIR_NAME)
}
pub fn report_for_home(self, home: &Path) -> HarnessReport {
let skills_dir = self.skills_dir_for_home(home);
let dest = skills_dir.join(SKILL_DIR_NAME).join("SKILL.md");
let (detected, detection_detail) = self.detect_for_home(home);
HarnessReport {
id: self,
skills_dir,
detected,
detection_detail,
installed: dest.is_file(),
}
}
fn detect_for_home(self, home: &Path) -> (bool, Option<String>) {
let mut signals: Vec<String> = Vec::new();
let mut push = |signal: &str| signals.push(signal.to_string());
match self {
HarnessId::Codex => {
if home.join(".codex").is_dir() {
push("~/.codex");
}
if home.join(".agents").join("skills").is_dir() {
push("~/.agents/skills");
}
if command_exists("codex") {
push("`codex` on PATH");
}
}
HarnessId::CodexInternal => {
if home.join(".codex-internal").is_dir() {
push("~/.codex-internal");
}
if command_exists("codex-internal") {
push("`codex-internal` on PATH");
}
}
HarnessId::ClaudeCode => {
if home.join(".claude").is_dir() {
push("~/.claude");
}
if command_exists("claude") {
push("`claude` on PATH");
}
}
HarnessId::ClaudeCodeInternal => {
if home.join(".claude-internal").is_dir() {
push("~/.claude-internal");
}
for cmd in [
"claude-internal",
"claudecode-internal",
"claude-code-internal",
] {
if command_exists(cmd) {
signals.push(format!("`{cmd}` on PATH"));
}
}
}
HarnessId::Cursor => {
if home.join(".cursor").is_dir() {
push("~/.cursor");
}
if command_exists("cursor") {
push("`cursor` on PATH");
}
}
HarnessId::Openclaw => {
if home.join(".openclaw").is_dir() {
push("~/.openclaw");
}
if command_exists("openclaw") {
push("`openclaw` on PATH");
}
}
HarnessId::OpenclawInternal => {
if home.join(".openclaw-internal").is_dir() {
push("~/.openclaw-internal");
}
if command_exists("openclaw-internal") {
push("`openclaw-internal` on PATH");
}
}
HarnessId::Codebuddy => {
if home.join(".codebuddy").is_dir() {
push("~/.codebuddy");
}
for cmd in ["codebuddy", "codebody"] {
if command_exists(cmd) {
signals.push(format!("`{cmd}` on PATH"));
}
}
}
HarnessId::Workbuddy => {
if home.join(".workbuddy").is_dir() {
push("~/.workbuddy");
}
if command_exists("workbuddy") {
push("`workbuddy` on PATH");
}
}
HarnessId::PiAgent => {
if home.join(".pi").join("agent").is_dir() {
push("~/.pi/agent");
} else if home.join(".pi").is_dir() {
push("~/.pi");
}
if command_exists("pi") {
push("`pi` on PATH");
}
}
HarnessId::Hermes => {
let hermes_home = hermes_home_for_user_home(home);
if hermes_home.is_dir() {
let label = hermes_home_signal_label(&hermes_home, home);
push(&label);
}
if command_exists("hermes") {
push("`hermes` on PATH");
}
}
HarnessId::KimiCode => {
let kimi_home = kimi_code_home_for_user_home(home);
if kimi_home.is_dir() {
let label = if std::env::var("KIMI_CODE_HOME")
.ok()
.is_some_and(|value| !value.trim().is_empty())
{
format!("$KIMI_CODE_HOME ({})", kimi_home.display())
} else {
"~/.kimi-code".to_string()
};
push(&label);
}
if command_exists("kimi") {
push("`kimi` on PATH");
}
}
}
let detected = !signals.is_empty();
let detail = if signals.is_empty() {
None
} else {
Some(signals.join(", "))
};
(detected, detail)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HarnessReport {
pub id: HarnessId,
pub skills_dir: PathBuf,
pub detected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub detection_detail: Option<String>,
pub installed: bool,
}
pub fn all_harness_reports() -> Result<Vec<HarnessReport>> {
let home = home_dir()?;
Ok(HarnessId::ALL
.iter()
.copied()
.map(|id| id.report_for_home(&home))
.collect())
}
pub fn parse_harness_id(raw: &str) -> Result<HarnessId> {
match raw.trim().to_ascii_lowercase().as_str() {
"codex" => Ok(HarnessId::Codex),
"codex-internal" | "codex_internal" | "codexinternal" => Ok(HarnessId::CodexInternal),
"claude-code" | "claude_code" | "claude" | "claudecode" | "cc" => Ok(HarnessId::ClaudeCode),
"claudecode-internal"
| "claude-code-internal"
| "claude_code_internal"
| "claude-internal"
| "claude_internal" => Ok(HarnessId::ClaudeCodeInternal),
"cursor" => Ok(HarnessId::Cursor),
"openclaw" | "open-claw" => Ok(HarnessId::Openclaw),
"openclaw-internal" | "openclaw_internal" | "open-claw-internal" => {
Ok(HarnessId::OpenclawInternal)
}
"codebuddy" | "code-body" | "codebody" | "code-buddy" => Ok(HarnessId::Codebuddy),
"workbuddy" | "work-buddy" => Ok(HarnessId::Workbuddy),
"pi" | "pi-agent" | "pi_agent" | "piagent" => Ok(HarnessId::PiAgent),
"hermes" | "hermes-agent" | "hermes_agent" | "hermesagent" => Ok(HarnessId::Hermes),
"kimi-code" | "kimi" | "kimicode" | "kimi-cli" | "kimi_cli" => Ok(HarnessId::KimiCode),
other => bail!(
"unknown harness '{other}'; expected one of: {}",
HarnessId::ALL
.iter()
.map(|h| h.cli_name())
.collect::<Vec<_>>()
.join(", ")
),
}
}
pub(crate) fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))
}
/// Kimi Code CLI 的数据根目录:`KIMI_CODE_HOME` 设置时用之,否则 `~/.kimi-code`
/// (见 kimi-code 官方 skills 文档:用户级 skills 在 `$KIMI_CODE_HOME/skills`)。
fn kimi_code_home_for_user_home(home: &Path) -> PathBuf {
if let Ok(override_home) = std::env::var("KIMI_CODE_HOME") {
let trimmed = override_home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
home.join(".kimi-code")
}
/// Mirrors [`get_hermes_home()`](https://github.com/NousResearch/hermes-agent/blob/main/hermes_constants.py):
/// `HERMES_HOME` when set, else `%LOCALAPPDATA%\\hermes` on native Windows, else `~/.hermes`.
fn hermes_home_for_user_home(home: &Path) -> PathBuf {
if let Ok(override_home) = std::env::var("HERMES_HOME") {
let trimmed = override_home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
#[cfg(windows)]
{
if let Ok(local) = std::env::var("LOCALAPPDATA") {
let trimmed = local.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed).join("hermes");
}
}
return home.join("AppData").join("Local").join("hermes");
}
#[cfg(not(windows))]
{
home.join(".hermes")
}
}
fn hermes_home_signal_label(hermes_home: &Path, user_home: &Path) -> String {
if std::env::var("HERMES_HOME")
.ok()
.is_some_and(|value| !value.trim().is_empty())
{
return format!("$HERMES_HOME ({})", hermes_home.display());
}
#[cfg(windows)]
{
let default = user_home.join("AppData").join("Local").join("hermes");
if hermes_home == default
|| std::env::var("LOCALAPPDATA")
.ok()
.is_some_and(|local| hermes_home == PathBuf::from(local.trim()).join("hermes"))
{
return "%LOCALAPPDATA%\\hermes".to_string();
}
}
if hermes_home == user_home.join(".hermes") {
return "~/.hermes".to_string();
}
hermes_home.display().to_string()
}
fn command_exists(name: &str) -> bool {
#[cfg(windows)]
{
std::process::Command::new("where")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(not(windows))]
{
std::process::Command::new("sh")
.arg("-c")
.arg(format!("command -v {name} >/dev/null 2>&1"))
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 读写 KIMI_CODE_HOME 环境变量的测试共用的互斥锁(Rust 测试默认并行)。
fn kimi_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
&LOCK
}
#[test]
fn parse_aliases() {
assert_eq!(parse_harness_id("codebody").unwrap(), HarnessId::Codebuddy);
assert_eq!(
parse_harness_id("claude-internal").unwrap(),
HarnessId::ClaudeCodeInternal
);
assert_eq!(parse_harness_id("pi-agent").unwrap(), HarnessId::PiAgent);
assert_eq!(parse_harness_id("hermes-agent").unwrap(), HarnessId::Hermes);
assert_eq!(parse_harness_id("kimi").unwrap(), HarnessId::KimiCode);
assert_eq!(parse_harness_id("kimi-code").unwrap(), HarnessId::KimiCode);
assert_eq!(parse_harness_id("kimi-cli").unwrap(), HarnessId::KimiCode);
}
#[test]
fn detects_cursor_from_home_layout() {
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".cursor")).unwrap();
let report = HarnessId::Cursor.report_for_home(home);
assert!(report.detected);
}
#[test]
fn openclaw_internal_not_detected_from_install_root_only() {
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".local/lib/openclaw-internal")).unwrap();
let report = HarnessId::OpenclawInternal.report_for_home(home);
assert!(!report.detected);
}
#[test]
fn skills_dirs_match_harness_spec() {
let home = Path::new("/home/user");
assert_eq!(
HarnessId::Codex.skills_dir_for_home(home),
PathBuf::from("/home/user/.agents/skills")
);
assert_eq!(
HarnessId::CodexInternal.skills_dir_for_home(home),
PathBuf::from("/home/user/.codex-internal/skills")
);
assert_eq!(
HarnessId::ClaudeCodeInternal.skills_dir_for_home(home),
PathBuf::from("/home/user/.claude-internal/skills")
);
assert_eq!(
HarnessId::OpenclawInternal.skills_dir_for_home(home),
PathBuf::from("/home/user/.openclaw-internal/skills")
);
assert_eq!(
HarnessId::Codebuddy.skills_dir_for_home(home),
PathBuf::from("/home/user/.codebuddy/skills")
);
assert_eq!(
HarnessId::Workbuddy.skills_dir_for_home(home),
PathBuf::from("/home/user/.workbuddy/skills")
);
assert_eq!(
HarnessId::PiAgent.skills_dir_for_home(home),
PathBuf::from("/home/user/.pi/agent/skills")
);
assert_eq!(
HarnessId::Hermes.skills_dir_for_home(home),
PathBuf::from("/home/user/.hermes/skills")
);
assert_eq!(
HarnessId::KimiCode.skills_dir_for_home(home),
PathBuf::from("/home/user/.kimi-code/skills")
);
}
#[test]
fn detects_kimi_code_from_home_layout() {
// 与改动 KIMI_CODE_HOME 的测试互斥,避免并行读写环境变量产生竞争。
let _guard = kimi_env_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".kimi-code")).unwrap();
let report = HarnessId::KimiCode.report_for_home(home);
assert!(report.detected);
assert_eq!(report.skills_dir, home.join(".kimi-code").join("skills"));
assert_eq!(HarnessId::KimiCode.cli_name(), "kimi-code");
assert_eq!(HarnessId::KimiCode.display_name(), "Kimi Code");
}
#[test]
fn kimi_code_skills_dir_honors_kimi_code_home_env() {
let _guard = kimi_env_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let tmp = tempfile::TempDir::new().unwrap();
let custom = tmp.path().join("custom-kimi-code");
std::fs::create_dir_all(&custom).unwrap();
let previous = std::env::var("KIMI_CODE_HOME").ok();
// SAFETY: 持有 kimi_env_lock,覆盖所有读写 KIMI_CODE_HOME 的测试。
unsafe {
std::env::set_var("KIMI_CODE_HOME", &custom);
}
let home = Path::new("/home/user");
assert_eq!(
HarnessId::KimiCode.skills_dir_for_home(home),
custom.join("skills")
);
unsafe {
match previous {
Some(value) => std::env::set_var("KIMI_CODE_HOME", value),
None => std::env::remove_var("KIMI_CODE_HOME"),
}
}
}
#[test]
fn detects_workbuddy_from_home_layout() {
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".workbuddy")).unwrap();
let report = HarnessId::Workbuddy.report_for_home(home);
assert!(report.detected);
assert_eq!(report.skills_dir, home.join(".workbuddy").join("skills"));
}
#[test]
fn detects_pi_agent_from_home_layout() {
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".pi").join("agent")).unwrap();
let report = HarnessId::PiAgent.report_for_home(home);
assert!(report.detected);
assert_eq!(
report.skills_dir,
home.join(".pi").join("agent").join("skills")
);
}
#[test]
fn detects_hermes_from_home_layout() {
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".hermes")).unwrap();
let report = HarnessId::Hermes.report_for_home(home);
assert!(report.detected);
assert_eq!(report.skills_dir, home.join(".hermes").join("skills"));
}
#[test]
fn hermes_skills_dir_honors_hermes_home_env() {
let tmp = tempfile::TempDir::new().unwrap();
let custom = tmp.path().join("custom-hermes");
std::fs::create_dir_all(&custom).unwrap();
let previous = std::env::var("HERMES_HOME").ok();
// SAFETY: harness tests do not run in parallel with other env-mutating tests.
unsafe {
std::env::set_var("HERMES_HOME", &custom);
}
let home = Path::new("/home/user");
assert_eq!(
HarnessId::Hermes.skills_dir_for_home(home),
custom.join("skills")
);
unsafe {
match previous {
Some(value) => std::env::set_var("HERMES_HOME", value),
None => std::env::remove_var("HERMES_HOME"),
}
}
}
}