mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
Compare commits
5 Commits
feat/relea
...
fix/skill-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685d5fef9f | ||
|
|
95e1290d23 | ||
|
|
9415d9c9af | ||
|
|
a121285a0e | ||
|
|
c0d30934e7 |
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -1753,6 +1753,7 @@ name = "tools"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
|
"commands",
|
||||||
"plugins",
|
"plugins",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"runtime",
|
"runtime",
|
||||||
|
|||||||
@@ -244,6 +244,14 @@ pub struct LineEditor {
|
|||||||
history: Vec<String>,
|
history: Vec<String>,
|
||||||
yank_buffer: YankBuffer,
|
yank_buffer: YankBuffer,
|
||||||
vim_enabled: bool,
|
vim_enabled: bool,
|
||||||
|
completion_state: Option<CompletionState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct CompletionState {
|
||||||
|
prefix: String,
|
||||||
|
matches: Vec<String>,
|
||||||
|
next_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LineEditor {
|
impl LineEditor {
|
||||||
@@ -255,6 +263,7 @@ impl LineEditor {
|
|||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
yank_buffer: YankBuffer::default(),
|
yank_buffer: YankBuffer::default(),
|
||||||
vim_enabled: false,
|
vim_enabled: false,
|
||||||
|
completion_state: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +366,10 @@ impl LineEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key_event(&mut self, session: &mut EditSession, key: KeyEvent) -> KeyAction {
|
fn handle_key_event(&mut self, session: &mut EditSession, key: KeyEvent) -> KeyAction {
|
||||||
|
if key.code != KeyCode::Tab {
|
||||||
|
self.completion_state = None;
|
||||||
|
}
|
||||||
|
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('c') | KeyCode::Char('C') => {
|
KeyCode::Char('c') | KeyCode::Char('C') => {
|
||||||
@@ -673,22 +686,62 @@ impl LineEditor {
|
|||||||
session.cursor = insert_at + self.yank_buffer.text.len();
|
session.cursor = insert_at + self.yank_buffer.text.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn complete_slash_command(&self, session: &mut EditSession) {
|
fn complete_slash_command(&mut self, session: &mut EditSession) {
|
||||||
if session.mode == EditorMode::Command {
|
if session.mode == EditorMode::Command {
|
||||||
|
self.completion_state = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(state) = self
|
||||||
|
.completion_state
|
||||||
|
.as_mut()
|
||||||
|
.filter(|_| session.cursor == session.text.len())
|
||||||
|
.filter(|state| {
|
||||||
|
state
|
||||||
|
.matches
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate == &session.text)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
let candidate = state.matches[state.next_index % state.matches.len()].clone();
|
||||||
|
state.next_index += 1;
|
||||||
|
session.text.replace_range(..session.cursor, &candidate);
|
||||||
|
session.cursor = candidate.len();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
|
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
|
||||||
|
self.completion_state = None;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(candidate) = self
|
let matches = self
|
||||||
.completions
|
.completions
|
||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
|
.filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
|
||||||
else {
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if matches.is_empty() {
|
||||||
|
self.completion_state = None;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = if let Some(state) = self
|
||||||
|
.completion_state
|
||||||
|
.as_mut()
|
||||||
|
.filter(|state| state.prefix == prefix && state.matches == matches)
|
||||||
|
{
|
||||||
|
let index = state.next_index % state.matches.len();
|
||||||
|
state.next_index += 1;
|
||||||
|
state.matches[index].clone()
|
||||||
|
} else {
|
||||||
|
let candidate = matches[0].clone();
|
||||||
|
self.completion_state = Some(CompletionState {
|
||||||
|
prefix: prefix.to_string(),
|
||||||
|
matches,
|
||||||
|
next_index: 1,
|
||||||
|
});
|
||||||
|
candidate
|
||||||
};
|
};
|
||||||
|
|
||||||
session.text.replace_range(..session.cursor, candidate);
|
session.text.replace_range(..session.cursor, &candidate);
|
||||||
session.cursor = candidate.len();
|
session.cursor = candidate.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,7 +1139,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tab_completes_matching_slash_commands() {
|
fn tab_completes_matching_slash_commands() {
|
||||||
// given
|
// given
|
||||||
let editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
|
let mut editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
|
||||||
let mut session = EditSession::new(false);
|
let mut session = EditSession::new(false);
|
||||||
session.text = "/he".to_string();
|
session.text = "/he".to_string();
|
||||||
session.cursor = session.text.len();
|
session.cursor = session.text.len();
|
||||||
@@ -1099,6 +1152,29 @@ mod tests {
|
|||||||
assert_eq!(session.cursor, 5);
|
assert_eq!(session.cursor, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_cycles_between_matching_slash_commands() {
|
||||||
|
// given
|
||||||
|
let mut editor = LineEditor::new(
|
||||||
|
"> ",
|
||||||
|
vec!["/permissions".to_string(), "/plugin".to_string()],
|
||||||
|
);
|
||||||
|
let mut session = EditSession::new(false);
|
||||||
|
session.text = "/p".to_string();
|
||||||
|
session.cursor = session.text.len();
|
||||||
|
|
||||||
|
// when
|
||||||
|
editor.complete_slash_command(&mut session);
|
||||||
|
let first = session.text.clone();
|
||||||
|
session.cursor = session.text.len();
|
||||||
|
editor.complete_slash_command(&mut session);
|
||||||
|
let second = session.text.clone();
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(first, "/permissions");
|
||||||
|
assert_eq!(second, "/plugin");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_c_cancels_when_input_exists() {
|
fn ctrl_c_cancels_when_input_exists() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api = { path = "../api" }
|
api = { path = "../api" }
|
||||||
|
commands = { path = "../commands" }
|
||||||
plugins = { path = "../plugins" }
|
plugins = { path = "../plugins" }
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use api::{
|
|||||||
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
||||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
|
use commands::resolve_skill_path as resolve_workspace_skill_path;
|
||||||
use plugins::PluginTool;
|
use plugins::PluginTool;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
@@ -1455,47 +1456,8 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
if requested.is_empty() {
|
resolve_workspace_skill_path(&cwd, skill).map_err(|error| error.to_string())
|
||||||
return Err(String::from("skill must not be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
|
||||||
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
|
|
||||||
}
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
|
||||||
let home = std::path::PathBuf::from(home);
|
|
||||||
candidates.push(home.join(".agents").join("skills"));
|
|
||||||
candidates.push(home.join(".config").join("opencode").join("skills"));
|
|
||||||
candidates.push(home.join(".codex").join("skills"));
|
|
||||||
}
|
|
||||||
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
|
|
||||||
|
|
||||||
for root in candidates {
|
|
||||||
let direct = root.join(requested).join("SKILL.md");
|
|
||||||
if direct.exists() {
|
|
||||||
return Ok(direct);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&root) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path().join("SKILL.md");
|
|
||||||
if !path.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if entry
|
|
||||||
.file_name()
|
|
||||||
.to_string_lossy()
|
|
||||||
.eq_ignore_ascii_case(requested)
|
|
||||||
{
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(format!("unknown skill: {requested}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||||
@@ -3488,6 +3450,92 @@ mod tests {
|
|||||||
.ends_with("/help/SKILL.md"));
|
.ends_with("/help/SKILL.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_resolves_project_and_plugin_scoped_prompts() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let workspace = temp_path("skill-workspace");
|
||||||
|
let home = temp_path("skill-home");
|
||||||
|
let plugin_root = home
|
||||||
|
.join(".claw")
|
||||||
|
.join("plugins")
|
||||||
|
.join("installed")
|
||||||
|
.join("oh-my-claudecode-external");
|
||||||
|
let project_skill_root = workspace.join(".codex").join("skills").join("ralplan");
|
||||||
|
std::fs::create_dir_all(&project_skill_root).expect("project skill dir");
|
||||||
|
std::fs::write(
|
||||||
|
project_skill_root.join("SKILL.md"),
|
||||||
|
"---\nname: ralplan\ndescription: Project skill\n---\n",
|
||||||
|
)
|
||||||
|
.expect("project skill");
|
||||||
|
std::fs::create_dir_all(plugin_root.join(".claw-plugin")).expect("plugin manifest dir");
|
||||||
|
std::fs::write(
|
||||||
|
plugin_root.join(".claw-plugin").join("plugin.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "oh-my-claudecode",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Plugin skills"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("plugin manifest");
|
||||||
|
std::fs::create_dir_all(home.join(".claw")).expect("config home");
|
||||||
|
std::fs::write(
|
||||||
|
home.join(".claw").join("settings.json"),
|
||||||
|
r#"{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"oh-my-claudecode@external": true
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("settings");
|
||||||
|
let plugin_skill_root = plugin_root.join("skills").join("ralplan");
|
||||||
|
std::fs::create_dir_all(&plugin_skill_root).expect("plugin skill dir");
|
||||||
|
std::fs::write(
|
||||||
|
plugin_skill_root.join("SKILL.md"),
|
||||||
|
"---\nname: ralplan\ndescription: Plugin skill\n---\n",
|
||||||
|
)
|
||||||
|
.expect("plugin skill");
|
||||||
|
|
||||||
|
let original_dir = std::env::current_dir().expect("cwd");
|
||||||
|
let old_home = std::env::var_os("HOME");
|
||||||
|
let old_codex_home = std::env::var_os("CODEX_HOME");
|
||||||
|
std::env::set_current_dir(&workspace).expect("set cwd");
|
||||||
|
std::env::set_var("HOME", &home);
|
||||||
|
std::env::remove_var("CODEX_HOME");
|
||||||
|
|
||||||
|
let project_result = execute_tool("Skill", &json!({ "skill": "ralplan" }))
|
||||||
|
.expect("project skill should resolve");
|
||||||
|
let project_output: serde_json::Value =
|
||||||
|
serde_json::from_str(&project_result).expect("valid json");
|
||||||
|
assert!(project_output["path"]
|
||||||
|
.as_str()
|
||||||
|
.expect("path")
|
||||||
|
.ends_with(".codex/skills/ralplan/SKILL.md"));
|
||||||
|
|
||||||
|
let plugin_result =
|
||||||
|
execute_tool("Skill", &json!({ "skill": "$oh-my-claudecode:ralplan" }))
|
||||||
|
.expect("plugin skill should resolve");
|
||||||
|
let plugin_output: serde_json::Value =
|
||||||
|
serde_json::from_str(&plugin_result).expect("valid json");
|
||||||
|
assert!(plugin_output["path"]
|
||||||
|
.as_str()
|
||||||
|
.expect("path")
|
||||||
|
.ends_with("skills/ralplan/SKILL.md"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||||
|
match old_home {
|
||||||
|
Some(value) => std::env::set_var("HOME", value),
|
||||||
|
None => std::env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
match old_codex_home {
|
||||||
|
Some(value) => std::env::set_var("CODEX_HOME", value),
|
||||||
|
None => std::env::remove_var("CODEX_HOME"),
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(workspace);
|
||||||
|
let _ = std::fs::remove_dir_all(home);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_search_supports_keyword_and_select_queries() {
|
fn tool_search_supports_keyword_and_select_queries() {
|
||||||
let keyword = execute_tool(
|
let keyword = execute_tool(
|
||||||
|
|||||||
Reference in New Issue
Block a user