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:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,935 @@
|
||||
//! `skill` tool — OpenCode variant of the skill tool.
|
||||
//!
|
||||
//! Loads a user-defined skill by name, reads its `SKILL.md` content,
|
||||
//! lists up to 10 bundled files from the skill directory, and returns
|
||||
//! the result wrapped in `<skill_content>` XML.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::implementations::skills::skill::{SkillOutput, extract_skill_body, format_skill_name};
|
||||
use crate::implementations::skills::types::SkillInfo;
|
||||
use crate::types::requirements::{Expr, ToolRequirement};
|
||||
#[allow(unused_imports)]
|
||||
use crate::types::resources::{AvailableSkills, SharedResources};
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
// ─── Description ─────────────────────────────────────────────────────
|
||||
|
||||
const DESCRIPTION: &str = r#"Load a specialized skill that provides domain-specific instructions and workflows.
|
||||
|
||||
When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.
|
||||
|
||||
The skill will inject detailed instructions, workflows, and access to bundled resources into the conversation via a `<skill_content name="...">` block with the loaded content.
|
||||
|
||||
The following skills provide specialized sets of instructions for particular tasks.
|
||||
Invoke this tool to load a skill when a task matches one of the available skills listed below:
|
||||
|
||||
<available_skills>
|
||||
${%- if skills %}
|
||||
${%- for skill in skills %}
|
||||
<skill>
|
||||
<name>${{ skill.name }}</name>
|
||||
<description>${{ skill.description|e }}</description>
|
||||
<location>${{ skill.location }}</location>
|
||||
</skill>
|
||||
${%- endfor %}
|
||||
${%- else %}
|
||||
(No skills available. Skills can be added in ~/.kigi/skills/ or .kigi/skills/)
|
||||
${%- endif %}
|
||||
</available_skills>"#;
|
||||
|
||||
// ─── Input ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Input for the OpenCode `skill` tool.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SkillInput {
|
||||
/// The name of the skill to invoke
|
||||
#[schemars(description = "The name of the skill to invoke")]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ─── ToolInput conversions (via Dynamic variant) ─────────────────────
|
||||
|
||||
impl TryFrom<crate::types::tool_io::ToolInput> for SkillInput {
|
||||
type Error = String;
|
||||
fn try_from(value: crate::types::tool_io::ToolInput) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
crate::types::tool_io::ToolInput::Dynamic(v) => {
|
||||
serde_json::from_value(v).map_err(|e| format!("SkillInput: {e}"))
|
||||
}
|
||||
_ => Err("expected Dynamic variant for SkillInput".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkillInput> for crate::types::tool_io::ToolInput {
|
||||
fn from(value: SkillInput) -> Self {
|
||||
crate::types::tool_io::ToolInput::Dynamic(
|
||||
serde_json::to_value(value).expect("SkillInput serializes to JSON"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool ────────────────────────────────────────────────────────────
|
||||
|
||||
/// OpenCode skill tool — loads specialized skill instructions into the conversation.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SkillTool;
|
||||
|
||||
/// Result of looking up a skill by name.
|
||||
#[derive(Debug)]
|
||||
enum FindSkillResult<'a> {
|
||||
/// Exactly one match found.
|
||||
Found(&'a SkillInfo),
|
||||
/// Multiple skills share the same short name -- caller must use a qualified name.
|
||||
Ambiguous(Vec<String>),
|
||||
/// No skill matched.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Find a skill by name from the available skills list.
|
||||
///
|
||||
/// Supports both fully-qualified names (`"local:commit"`) and
|
||||
/// short names (`"commit"`).
|
||||
///
|
||||
/// When a short name matches multiple skills across scopes, returns
|
||||
/// `Ambiguous` with the qualified names so the caller can ask for
|
||||
/// disambiguation instead of silently picking first-match.
|
||||
fn find_skill<'a>(name: &str, skills: &'a [SkillInfo]) -> FindSkillResult<'a> {
|
||||
// First try exact match with fully qualified name -- always unambiguous.
|
||||
if let Some(skill) = skills
|
||||
.iter()
|
||||
.find(|s| s.enabled && format_skill_name(s) == name)
|
||||
{
|
||||
return FindSkillResult::Found(skill);
|
||||
}
|
||||
|
||||
// Short-name lookup: collect all matching skills (only enabled ones).
|
||||
let matches: Vec<&SkillInfo> = skills
|
||||
.iter()
|
||||
.filter(|s| s.enabled && s.name == name)
|
||||
.collect();
|
||||
match matches.len() {
|
||||
0 => FindSkillResult::NotFound,
|
||||
1 => FindSkillResult::Found(matches[0]),
|
||||
_ => {
|
||||
let qualified: Vec<String> = matches.iter().map(|s| format_skill_name(s)).collect();
|
||||
FindSkillResult::Ambiguous(qualified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load skill content from its SKILL.md file, stripping YAML frontmatter.
|
||||
async fn load_skill_content(skill: &SkillInfo) -> Result<String, String> {
|
||||
let path = Path::new(&skill.path);
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(content) => Ok(extract_skill_body(&content)),
|
||||
Err(e) => Err(format!("Failed to read skill file '{}': {}", skill.path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// List up to `limit` bundled files in the skill directory, excluding SKILL.md.
|
||||
async fn list_skill_files(skill: &SkillInfo, limit: usize) -> Vec<String> {
|
||||
let skill_path = Path::new(&skill.path);
|
||||
let dir = match skill_path.parent() {
|
||||
Some(d) => d,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if files.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
// Skip the SKILL.md file itself.
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case("SKILL.md"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
files.push(path.display().to_string());
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// Format the `<skill_content>` XML output block.
|
||||
fn format_output(skill: &SkillInfo, content: &str, files: &[String]) -> String {
|
||||
let base_dir = Path::new(&skill.path)
|
||||
.parent()
|
||||
.map(|p| format!("file://{}", p.display()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let files_xml: String = files
|
||||
.iter()
|
||||
.map(|f| format!("<file>{f}</file>"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("<skill_content name=\"{}\">\n", skill.name));
|
||||
out.push_str(&format!("# Skill: {}\n\n", skill.name));
|
||||
out.push_str(content.trim());
|
||||
out.push_str("\n\n");
|
||||
out.push_str(&format!("Base directory for this skill: {base_dir}\n"));
|
||||
out.push_str(
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\n",
|
||||
);
|
||||
out.push_str("Note: file list is sampled.\n\n");
|
||||
out.push_str("<skill_files>\n");
|
||||
out.push_str(&files_xml);
|
||||
out.push('\n');
|
||||
out.push_str("</skill_files>\n");
|
||||
out.push_str("</skill_content>");
|
||||
out
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
impl crate::types::tool_metadata::ToolMetadata for SkillTool {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Skill
|
||||
}
|
||||
|
||||
fn tool_namespace(&self) -> ToolNamespace {
|
||||
ToolNamespace::OpenCode
|
||||
}
|
||||
|
||||
fn description_template(&self) -> &str {
|
||||
DESCRIPTION
|
||||
}
|
||||
|
||||
fn requires_expr(&self) -> Expr<ToolRequirement> {
|
||||
Expr::True
|
||||
}
|
||||
}
|
||||
|
||||
impl kigi_tool_runtime::Tool for SkillTool {
|
||||
type Args = SkillInput;
|
||||
type Output = SkillOutput;
|
||||
|
||||
fn id(&self) -> kigi_tool_protocol::ToolId {
|
||||
kigi_tool_protocol::ToolId::new("skill").expect("valid tool id")
|
||||
}
|
||||
|
||||
fn description(
|
||||
&self,
|
||||
_ctx: &::kigi_tool_runtime::ListToolsContext,
|
||||
) -> kigi_tool_types::ToolDescription {
|
||||
kigi_tool_types::ToolDescription::new(
|
||||
"skill",
|
||||
crate::types::tool_metadata::ToolMetadata::description_template(self),
|
||||
)
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
|
||||
kigi_tool_protocol::ToolCapabilities {
|
||||
is_read_only: false,
|
||||
tool_scope: Some(kigi_tool_protocol::ToolScope::Write),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "tool.opencode_skill", skip_all, fields(skill_name = %input.name))]
|
||||
async fn run(
|
||||
&self,
|
||||
ctx: kigi_tool_runtime::ToolCallContext,
|
||||
input: SkillInput,
|
||||
) -> Result<SkillOutput, kigi_tool_runtime::ToolError> {
|
||||
use crate::types::tool_metadata::shared_resources;
|
||||
let resources = shared_resources(&ctx)?;
|
||||
|
||||
let available_skills;
|
||||
{
|
||||
available_skills = resources
|
||||
.lock()
|
||||
.await
|
||||
.get::<AvailableSkills>()
|
||||
.map(|a| a.0.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
// ── Look up the skill ────────────────────────────────────────
|
||||
let skill = match find_skill(&input.name, &available_skills) {
|
||||
FindSkillResult::Found(s) => s.clone(),
|
||||
FindSkillResult::Ambiguous(qualified) => {
|
||||
return Ok(SkillOutput {
|
||||
success: false,
|
||||
tool_result: format!(
|
||||
"Skill '{}' is ambiguous -- multiple skills share this name. \
|
||||
Use a qualified name: {}",
|
||||
input.name,
|
||||
qualified.join(", ")
|
||||
),
|
||||
skill_name: input.name.clone(),
|
||||
skill_message: None,
|
||||
error: Some(format!(
|
||||
"Ambiguous skill '{}': use one of {}",
|
||||
input.name,
|
||||
qualified.join(", ")
|
||||
)),
|
||||
});
|
||||
}
|
||||
FindSkillResult::NotFound => {
|
||||
let names: Vec<&str> = available_skills.iter().map(|s| s.name.as_str()).collect();
|
||||
let available = if names.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
names.join(", ")
|
||||
};
|
||||
return Ok(SkillOutput {
|
||||
success: false,
|
||||
tool_result: format!(
|
||||
"Skill \"{}\" not found. Available skills: {}",
|
||||
input.name, available
|
||||
),
|
||||
skill_name: input.name.clone(),
|
||||
skill_message: None,
|
||||
error: Some(format!("Skill \"{}\" not found", input.name)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── Load the skill content ───────────────────────────────────
|
||||
let content = match load_skill_content(&skill).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(SkillOutput {
|
||||
success: false,
|
||||
tool_result: format!("Failed to load skill '{}': {}", skill.name, e),
|
||||
skill_name: skill.name.clone(),
|
||||
skill_message: None,
|
||||
error: Some(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── List bundled files (up to 10) ────────────────────────────
|
||||
let files = list_skill_files(&skill, 10).await;
|
||||
|
||||
// ── Build output ─────────────────────────────────────────────
|
||||
let output = format_output(&skill, &content, &files);
|
||||
|
||||
Ok(SkillOutput {
|
||||
success: true,
|
||||
tool_result: format!("Loaded skill: {}", skill.name),
|
||||
skill_name: skill.name.clone(),
|
||||
skill_message: Some(output),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::tool_metadata::test_ctx;
|
||||
|
||||
use crate::implementations::skills::types::SkillScope;
|
||||
use crate::types::resources::Resources;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_test_skill(name: &str, scope: SkillScope, path: &str) -> SkillInfo {
|
||||
SkillInfo {
|
||||
name: name.to_string(),
|
||||
display_name: None,
|
||||
description: format!("Test skill: {}", name),
|
||||
short_description: None,
|
||||
author: None,
|
||||
argument_hint: None,
|
||||
path: path.to_string(),
|
||||
scope,
|
||||
config_source: None,
|
||||
plugin_name: None,
|
||||
plugin_version: None,
|
||||
plugin_root: None,
|
||||
plugin_data: None,
|
||||
allowed_tools: None,
|
||||
license: None,
|
||||
compatibility: None,
|
||||
metadata: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
user_invocable: true,
|
||||
disable_model_invocation: false,
|
||||
when_to_use: None,
|
||||
has_user_specified_description: false,
|
||||
paths: None,
|
||||
enabled: true,
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_metadata() {
|
||||
use crate::types::tool_metadata::ToolMetadata;
|
||||
let tool = SkillTool;
|
||||
assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "skill");
|
||||
assert!(matches!(tool.kind(), ToolKind::Skill));
|
||||
assert!(matches!(tool.tool_namespace(), ToolNamespace::OpenCode));
|
||||
let desc = tool.description_template();
|
||||
assert!(desc.contains("available_skills"));
|
||||
assert!(desc.contains("${%- for skill in skills %}"));
|
||||
assert!(desc.contains("${{ skill.name }}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_skill_by_short_name() {
|
||||
let skills = vec![
|
||||
make_test_skill("commit", SkillScope::User, "/path/a"),
|
||||
make_test_skill("build", SkillScope::Local, "/path/b"),
|
||||
];
|
||||
|
||||
assert!(
|
||||
matches!(find_skill("commit", &skills), FindSkillResult::Found(s) if s.name == "commit")
|
||||
);
|
||||
assert!(
|
||||
matches!(find_skill("build", &skills), FindSkillResult::Found(s) if s.name == "build")
|
||||
);
|
||||
assert!(matches!(
|
||||
find_skill("nonexistent", &skills),
|
||||
FindSkillResult::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_skill_by_qualified_name() {
|
||||
let skills = vec![
|
||||
make_test_skill("commit", SkillScope::User, "/path/a"),
|
||||
make_test_skill("build", SkillScope::Local, "/path/b"),
|
||||
];
|
||||
|
||||
assert!(
|
||||
matches!(find_skill("user:commit", &skills), FindSkillResult::Found(s) if s.name == "commit")
|
||||
);
|
||||
assert!(
|
||||
matches!(find_skill("local:build", &skills), FindSkillResult::Found(s) if s.name == "build")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_skill_ambiguous_short_name_rejected() {
|
||||
let skills = vec![
|
||||
make_test_skill("commit", SkillScope::Local, "/path/local"),
|
||||
make_test_skill("commit", SkillScope::User, "/path/user"),
|
||||
];
|
||||
|
||||
// Short name "commit" matches two scopes -- should be Ambiguous.
|
||||
let result = find_skill("commit", &skills);
|
||||
match result {
|
||||
FindSkillResult::Ambiguous(qualified) => {
|
||||
assert!(qualified.contains(&"local:commit".to_string()));
|
||||
assert!(qualified.contains(&"user:commit".to_string()));
|
||||
}
|
||||
other => panic!("Expected Ambiguous, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_skill_qualified_resolves_despite_ambiguous_short_name() {
|
||||
let skills = vec![
|
||||
make_test_skill("commit", SkillScope::Local, "/path/local"),
|
||||
make_test_skill("commit", SkillScope::User, "/path/user"),
|
||||
];
|
||||
|
||||
// Qualified names still resolve unambiguously.
|
||||
assert!(matches!(
|
||||
find_skill("local:commit", &skills),
|
||||
FindSkillResult::Found(s) if s.path == "/path/local"
|
||||
));
|
||||
assert!(matches!(
|
||||
find_skill("user:commit", &skills),
|
||||
FindSkillResult::Found(s) if s.path == "/path/user"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_ambiguous_returns_error_output() {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![
|
||||
make_test_skill("commit", SkillScope::Local, "/path/local"),
|
||||
make_test_skill("commit", SkillScope::User, "/path/user"),
|
||||
]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "commit".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!output.success);
|
||||
assert!(output.tool_result.contains("ambiguous"));
|
||||
assert!(output.tool_result.contains("local:commit"));
|
||||
assert!(output.tool_result.contains("user:commit"));
|
||||
assert!(output.error.is_some());
|
||||
assert!(output.skill_message.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_not_found_returns_error_output() {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"commit",
|
||||
SkillScope::User,
|
||||
"/path/a",
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "nonexistent".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!output.success);
|
||||
assert!(output.tool_result.contains("not found"));
|
||||
assert!(output.tool_result.contains("commit"));
|
||||
assert!(output.error.is_some());
|
||||
assert!(output.skill_message.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_not_found_with_no_skills_resource() {
|
||||
let resources = Resources::new();
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "commit".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!output.success);
|
||||
assert!(output.tool_result.contains("not found"));
|
||||
assert!(output.tool_result.contains("none"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_loads_content_from_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_path,
|
||||
"---\nname: test\ndescription: A test\n---\n# Test Skill\n\nDo the thing.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"test",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "test".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
assert_eq!(output.skill_name, "test");
|
||||
assert_eq!(output.tool_result, "Loaded skill: test");
|
||||
|
||||
let msg = output.skill_message.unwrap();
|
||||
assert!(msg.contains("<skill_content name=\"test\">"));
|
||||
assert!(msg.contains("# Skill: test"));
|
||||
assert!(msg.contains("# Test Skill"));
|
||||
assert!(msg.contains("Do the thing."));
|
||||
assert!(msg.contains("<skill_files>"));
|
||||
assert!(msg.contains("</skill_content>"));
|
||||
}
|
||||
|
||||
/// Regression: the OpenCode skill tool's body must reach the model via the
|
||||
/// normal tool result (`to_prompt_format()`). It was previously dropped
|
||||
/// because OpenCode registers the skill tool without a follow-up handler.
|
||||
#[tokio::test]
|
||||
async fn skill_body_reaches_prompt_format() {
|
||||
use crate::types::output::ToolOutput;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_path,
|
||||
"---\nname: test\ndescription: A test\n---\n# Test Skill\n\nDo the thing.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"test",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "test".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let prompt = ToolOutput::Skill(output).to_prompt_format();
|
||||
assert!(
|
||||
prompt.contains("Do the thing."),
|
||||
"skill body must reach to_prompt_format(), got: {prompt}"
|
||||
);
|
||||
assert!(prompt.contains("<skill_content name=\"test\">"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lists_bundled_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(&skill_path, "# Skill\n\nContent.").unwrap();
|
||||
std::fs::write(tmp.path().join("helper.sh"), "#!/bin/bash").unwrap();
|
||||
std::fs::write(tmp.path().join("reference.md"), "# Ref").unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"test",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "test".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let msg = output.skill_message.unwrap();
|
||||
assert!(msg.contains("<file>"));
|
||||
// SKILL.md itself should NOT appear in the file list.
|
||||
assert!(!msg.contains("<file>") || !msg.contains("SKILL.md"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_file_not_found_returns_error() {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"missing",
|
||||
SkillScope::Local,
|
||||
"/nonexistent/path/SKILL.md",
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "missing".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!output.success);
|
||||
assert!(output.tool_result.contains("Failed to load"));
|
||||
assert!(output.error.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn works_through_erased_interface() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(&skill_path, "# Hello\n\nContent.").unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"hello",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let result = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "hello".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(result.skill_name, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontmatter_stripping() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_path,
|
||||
"---\nname: deploy\ndescription: Deploy to prod\ntags: [ops]\n---\nRun the deploy pipeline.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"deploy",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "deploy".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let msg = output.skill_message.unwrap();
|
||||
// Body content is present.
|
||||
assert!(msg.contains("Run the deploy pipeline."));
|
||||
// Frontmatter delimiters and keys are stripped.
|
||||
assert!(!msg.contains("---"));
|
||||
assert!(!msg.contains("tags: [ops]"));
|
||||
assert!(!msg.contains("description: Deploy to prod"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_message_xml_structure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(&skill_path, "---\nname: fmt\n---\nFormat the code.").unwrap();
|
||||
std::fs::write(tmp.path().join("fmt.sh"), "#!/bin/bash").unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"fmt",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput { name: "fmt".into() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let msg = output.skill_message.unwrap();
|
||||
|
||||
// Must open with <skill_content name="fmt">
|
||||
assert!(msg.starts_with("<skill_content name=\"fmt\">\n"));
|
||||
// Must have # Skill: fmt header
|
||||
assert!(msg.contains("# Skill: fmt\n"));
|
||||
// Must contain the body content
|
||||
assert!(msg.contains("Format the code."));
|
||||
// Must have Base directory line
|
||||
assert!(msg.contains(&format!(
|
||||
"Base directory for this skill: file://{}",
|
||||
tmp.path().display()
|
||||
)));
|
||||
// Must have <skill_files> block
|
||||
assert!(msg.contains("<skill_files>\n"));
|
||||
// Must list the bundled file
|
||||
assert!(msg.contains(&format!(
|
||||
"<file>{}</file>",
|
||||
tmp.path().join("fmt.sh").display()
|
||||
)));
|
||||
// Must close with </skill_content>
|
||||
assert!(msg.ends_with("</skill_content>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_directory_in_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path().join("my-skills").join("linter");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_path, "Lint all files.").unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"linter",
|
||||
SkillScope::User,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "linter".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let msg = output.skill_message.unwrap();
|
||||
let expected_base = format!(
|
||||
"Base directory for this skill: file://{}",
|
||||
skill_dir.display()
|
||||
);
|
||||
assert!(
|
||||
msg.contains(&expected_base),
|
||||
"Expected base dir line:\n {expected_base}\nFull message:\n{msg}"
|
||||
);
|
||||
assert!(msg.contains("Relative paths in this skill"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_with_no_bundled_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(&skill_path, "Solo skill, no extras.").unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"solo",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "solo".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let msg = output.skill_message.unwrap();
|
||||
// <skill_files> block must be present but contain no <file> entries.
|
||||
assert!(msg.contains("<skill_files>"));
|
||||
assert!(msg.contains("</skill_files>"));
|
||||
assert!(!msg.contains("<file>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_skill_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
// SKILL.md with only frontmatter — body after stripping is empty.
|
||||
std::fs::write(&skill_path, "---\nname: empty\ndescription: nothing\n---\n").unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"empty",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "empty".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should succeed gracefully even with no body content.
|
||||
assert!(output.success);
|
||||
assert_eq!(output.tool_result, "Loaded skill: empty");
|
||||
assert!(output.error.is_none());
|
||||
|
||||
let msg = output.skill_message.unwrap();
|
||||
assert!(msg.contains("<skill_content name=\"empty\">"));
|
||||
assert!(msg.contains("# Skill: empty"));
|
||||
assert!(msg.contains("</skill_content>"));
|
||||
// No frontmatter keys leaked into output.
|
||||
assert!(!msg.contains("description: nothing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ten_file_cap() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_path = tmp.path().join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_path,
|
||||
"---\nname: bigskill\ndescription: lots of files\n---\nDo stuff.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create 15 extra files in the skill directory.
|
||||
for i in 1..=15 {
|
||||
std::fs::write(
|
||||
tmp.path().join(format!("file{i:02}.txt")),
|
||||
format!("content {i}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(AvailableSkills(vec![make_test_skill(
|
||||
"bigskill",
|
||||
SkillScope::Local,
|
||||
skill_path.to_str().unwrap(),
|
||||
)]));
|
||||
|
||||
let tool = SkillTool;
|
||||
let output = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
SkillInput {
|
||||
name: "bigskill".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(output.success);
|
||||
let msg = output.skill_message.unwrap();
|
||||
let file_tag_count = msg.matches("<file>").count();
|
||||
assert!(
|
||||
file_tag_count <= 10,
|
||||
"Expected at most 10 <file> entries, got {file_tag_count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user