@sigitcode-old / sigit / commits / ad7fb48
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

feat: run Agent Skills as /<skill-name> slash commands

An unrecognized slash word fell back to custom commands only, so a skill could not be invoked as /name, and Zed never learned about skills. skills::slash_prompt looks a skill up by name and returns its activation text (shared with the skill tool via render_activation), plus the task when one follows the name. The ACP and TUI Unknown arms try custom commands first, then skills, then report an unknown command. The ACP command list now includes each skill with a "task (optional)" hint and a description cut to one line of about 100 characters. A skill named like a built-in or custom command is skipped with a warning, as custom commands already are. /help mentions /<skill-name>. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 29, 2026 at 17:33 UTC ad7fb48648497eaaab434ad89010ac50d00c6e74
4 files changed +350 -23
crates/cli/src/chat.rs
+18 -10
@@ -2907,6 +2907,7 @@ mod tui {
2907 /skills — list available Agent Skills\n\
2908 /agents — list configured subagent types (.sigit/agents/*.md)\n\
2909 /commands — list user-defined commands (.sigit/commands/*.md)\n\
2910 + /<skill-name> [task] — run an Agent Skill, optionally on a task\n\
2911 /mcp — list MCP servers and their tools\n\
2912 /load — load the selected on-device model\n\
2913 /login — sign in via your browser\n\
@@ -4013,16 +4014,23 @@ mod tui {
4014 argument.as_deref(),
4015 );
4016 }
4016 - None => {
4017 - exec_slash(
4018 - &mut app,
4019 - SlashCommand::Unknown(command, argument),
4020 - Arc::clone(&engine),
4021 - terminal,
4022 - )
4023 - .await;
4024 - continue;
4025 - }
4017 + // Then a skill: `/<skill-name> [task]`.
4018 + None => match crate::skills::slash_prompt(
4019 + &command,
4020 + argument.as_deref(),
4021 + ) {
4022 + Some(prompt) => inference_text = prompt,
4023 + None => {
4024 + exec_slash(
4025 + &mut app,
4026 + SlashCommand::Unknown(command, argument),
4027 + Arc::clone(&engine),
4028 + terminal,
4029 + )
4030 + .await;
4031 + continue;
4032 + }
4033 + },
4034 }
4035 }
4036 Some(cmd) => {
crates/cli/src/main.rs
+50 -13
@@ -433,6 +433,18 @@ fn tool_arguments_content(arguments_json: &str) -> ToolCallContent {
433 fenced_code_block(language, &chat::pretty_tool_arguments(arguments_json)).into()
434 }
435
436 +/// A skill description cut to one line of about 100 characters, so a client's
437 +/// command popup stays readable.
438 +fn one_line_description(description: &str) -> String {
439 + const MAX: usize = 100;
440 + let line = description.split_whitespace().collect::<Vec<_>>().join(" ");
441 + if line.chars().count() <= MAX {
442 + return line;
443 + }
444 + let cut: String = line.chars().take(MAX - 3).collect();
445 + format!("{}...", cut.trim_end())
446 +}
447 +
448 /// Splits a notice into its title (the first non-empty line) and the body
449 /// (everything after it), if there is any.
450 fn split_notice(text: &str) -> (String, Option<String>) {
@@ -1885,6 +1897,26 @@ impl SiGitAgent {
1897 });
1898 }
1899
1900 + // Skills come last, so `/` in the client lists them next to the
1901 + // commands. A skill sharing a name with a built-in or custom command
1902 + // is unreachable as `/name` (those win), so it is not advertised.
1903 + let taken: Vec<String> = commands.iter().map(|c| c.name.to_string()).collect();
1904 + for skill in skills::discover_skills() {
1905 + if taken.contains(&skill.name) {
1906 + log::warn!(
1907 + "skill \"{}\" at {} is shadowed by a command of the same name",
1908 + skill.name,
1909 + skill.dir.display()
1910 + );
1911 + continue;
1912 + }
1913 + commands.push(with_hint(
1914 + &skill.name,
1915 + &one_line_description(&skill.description),
1916 + "task (optional)",
1917 + ));
1918 + }
1919 +
1920 self.send_tool_call_update(
1921 cx,
1922 session_id,
@@ -3532,22 +3564,26 @@ impl SiGitAgent {
3564 // permission checks.
3565 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
3566 // A word `parse_slash` didn't recognize might be a user-defined
3535 - // command (`.sigit/commands/*.md`) — same substitute-and-run-a-
3536 - // real-turn treatment as `/init`. Only fall back to "unknown
3537 - // command" once that lookup also misses.
3567 + // command (`.sigit/commands/*.md`) or a skill — same substitute-
3568 + // and-run-a-real-turn treatment as `/init`. Only fall back to
3569 + // "unknown command" once both lookups miss.
3570 Some(SlashCommand::Unknown(command, argument)) => {
3571 match commands::resolve_command(&command) {
3572 Some(custom) => commands::render(&custom.body, argument.as_deref()),
3541 - None => {
3542 - return self
3543 - .run_slash(
3544 - cx,
3545 - session_id,
3546 - SlashCommand::Unknown(command, argument),
3547 - &cancellation,
3548 - )
3549 - .await;
3550 - }
3573 + // Then a skill: `/<skill-name> [task]` activates it.
3574 + None => match skills::slash_prompt(&command, argument.as_deref()) {
3575 + Some(prompt) => prompt,
3576 + None => {
3577 + return self
3578 + .run_slash(
3579 + cx,
3580 + session_id,
3581 + SlashCommand::Unknown(command, argument),
3582 + &cancellation,
3583 + )
3584 + .await;
3585 + }
3586 + },
3587 }
3588 }
3589 Some(command) => return self.run_slash(cx, session_id, command, &cancellation).await,
@@ -5696,6 +5732,7 @@ async fn exec_slash_acp(
5732 /skills - list available Agent Skills\n\
5733 /agents - list configured subagent types (.sigit/agents/*.md)\n\
5734 /commands - list user-defined commands (.sigit/commands/*.md)\n\
5735 + /<skill-name> [task] - run an Agent Skill, optionally on a task\n\
5736 /mcp - list MCP servers and their tools\n\
5737 /load - load the selected on-device model\n\
5738 /login E P - sign in to siGit Code Cloud\n\
crates/cli/src/skills.rs
+55
@@ -143,6 +143,14 @@ pub fn activate_skill(arguments: &str) -> String {
143 return format!("Error: no skill named \"{name}\". Available skills: {available}.");
144 };
145
146 + render_activation(skill)
147 +}
148 +
149 +/// The text handed to the model when `skill` is activated: its `SKILL.md` body
150 +/// under a header naming the skill's directory, or an error line when the file
151 +/// cannot be read.
152 +fn render_activation(skill: &Skill) -> String {
153 + let name = &skill.name;
154 let body = match read_skill_body(&skill.skill_md()) {
155 Ok(body) => body,
156 Err(err) => {
@@ -174,6 +182,20 @@ pub fn activate_skill(arguments: &str) -> String {
182 )
183 }
184
185 +/// The prompt for a `/<skill-name> [task]` command, or `None` when no skill has
186 +/// that name. The activation text comes first, then the user's task if one was
187 +/// given. Accepts the name with or without a leading `/`.
188 +pub fn slash_prompt(name: &str, argument: Option<&str>) -> Option<String> {
189 + let name = name.strip_prefix('/').unwrap_or(name);
190 + let skill = discover_skills().into_iter().find(|s| s.name == name)?;
191 + let mut prompt = render_activation(&skill);
192 + if let Some(task) = argument.map(str::trim).filter(|t| !t.is_empty()) {
193 + prompt.push_str("\n\nTask: ");
194 + prompt.push_str(task);
195 + }
196 + Some(prompt)
197 +}
198 +
199 /// Discover all valid skills across the known roots. Earlier roots win when two
200 /// skills share a `name`. The result is sorted by name for stable output.
201 pub fn discover_skills() -> Vec<Skill> {
@@ -448,6 +470,39 @@ mod tests {
470 let _ = fs::remove_dir_all(&root);
471 }
472
473 + #[test]
474 + fn slash_prompt_activates_a_skill_and_appends_the_task() {
475 + let _guard = crate::ENV_TEST_LOCK
476 + .lock()
477 + .unwrap_or_else(|poisoned| poisoned.into_inner());
478 + let root = unique_dir("slash-prompt");
479 + let skill_dir = root.join(".sigit").join("skills").join("tidy-up");
480 + fs::create_dir_all(&skill_dir).unwrap();
481 + fs::write(
482 + skill_dir.join("SKILL.md"),
483 + "---\nname: tidy-up\ndescription: Tidy things.\n---\n\nSort the shelves.\n",
484 + )
485 + .unwrap();
486 +
487 + let prev = std::env::current_dir().unwrap();
488 + std::env::set_current_dir(&root).unwrap();
489 +
490 + let bare = slash_prompt("tidy-up", None).expect("skill found");
491 + assert!(bare.contains("Sort the shelves."));
492 + assert!(!bare.contains("Task:"));
493 +
494 + let with_task = slash_prompt("/tidy-up", Some(" the garage ")).expect("slash accepted");
495 + assert!(with_task.starts_with(&bare));
496 + assert!(with_task.ends_with("\n\nTask: the garage"));
497 +
498 + let blank = slash_prompt("tidy-up", Some(" ")).unwrap();
499 + assert_eq!(blank, bare);
500 + assert!(slash_prompt("nope", None).is_none());
501 +
502 + std::env::set_current_dir(prev).unwrap();
503 + let _ = fs::remove_dir_all(&root);
504 + }
505 +
506 #[test]
507 fn skill_tool_description_lists_skills() {
508 let skills = vec![Skill {
crates/cli/tests/acp_skill_commands.rs new
+227
@@ -0,0 +1,227 @@
1 +//! Skills are slash commands: `/<skill-name> [task]` activates the skill and
2 +//! runs a turn, and the name shows up in the client's command list.
3 +
4 +use std::io::{BufRead, BufReader, Read, Write};
5 +use std::net::TcpListener;
6 +use std::process::{Child, ChildStdin, Command, Stdio};
7 +use std::sync::mpsc::{Receiver, Sender, channel};
8 +use std::time::{Duration, Instant};
9 +
10 +use serde_json::{Value, json};
11 +
12 +const TIMEOUT: Duration = Duration::from_secs(60);
13 +
14 +/// Answers every chat completion with a short reply and reports each request
15 +/// body on the returned channel.
16 +fn start_fake_endpoint() -> (u16, Receiver<String>) {
17 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
18 + let port = listener.local_addr().unwrap().port();
19 + let (bodies, received): (Sender<String>, Receiver<String>) = channel();
20 + std::thread::spawn(move || {
21 + for stream in listener.incoming() {
22 + let Ok(mut stream) = stream else { continue };
23 + let Ok(clone) = stream.try_clone() else {
24 + continue;
25 + };
26 + let mut reader = BufReader::new(clone);
27 + let mut content_length = 0usize;
28 + loop {
29 + let mut line = String::new();
30 + if reader.read_line(&mut line).unwrap_or(0) == 0 {
31 + break;
32 + }
33 + let line = line.trim();
34 + if line.is_empty() {
35 + break;
36 + }
37 + if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
38 + content_length = length.trim().parse().unwrap_or(0);
39 + }
40 + }
41 + let mut body = vec![0u8; content_length];
42 + if reader.read_exact(&mut body).is_err() {
43 + continue;
44 + }
45 + let _ = bodies.send(String::from_utf8_lossy(&body).into_owned());
46 + let payload = format!(
47 + "data: {}\n\ndata: [DONE]\n\n",
48 + json!({"choices": [{"delta": {"content": "Done."}}]})
49 + );
50 + let response = format!(
51 + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
52 + content-length: {}\r\nconnection: close\r\n\r\n{}",
53 + payload.len(),
54 + payload
55 + );
56 + let _ = stream.write_all(response.as_bytes());
57 + }
58 + });
59 + (port, received)
60 +}
61 +
62 +struct Wire {
63 + child: Child,
64 + stdin: ChildStdin,
65 + incoming: Receiver<Value>,
66 + next_id: u64,
67 +}
68 +
69 +fn spawn(port: u16, config_dir: &std::path::Path) -> Wire {
70 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
71 + .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
72 + .env("OPENAI_API_KEY", "test-key")
73 + .env("SIGIT_MODEL", "scripted-model")
74 + .env("SIGIT_CONFIG_DIR", config_dir)
75 + .env("SIGIT_MCP", "off")
76 + .env("SIGIT_PERMISSIONS", "allow")
77 + .env_remove("SIGIT_LOCAL_INFERENCE")
78 + .stdin(Stdio::piped())
79 + .stdout(Stdio::piped())
80 + .stderr(Stdio::null())
81 + .spawn()
82 + .expect("spawn sigit in ACP mode");
83 + let stdout = child.stdout.take().unwrap();
84 + let (tx, incoming) = channel();
85 + std::thread::spawn(move || {
86 + for line in BufReader::new(stdout).lines() {
87 + let Ok(line) = line else { break };
88 + if let Ok(message) = serde_json::from_str::<Value>(&line)
89 + && tx.send(message).is_err()
90 + {
91 + break;
92 + }
93 + }
94 + });
95 + let stdin = child.stdin.take().unwrap();
96 + Wire {
97 + child,
98 + stdin,
99 + incoming,
100 + next_id: 0,
101 + }
102 +}
103 +
104 +impl Wire {
105 + fn request(&mut self, method: &str, params: Value) -> u64 {
106 + self.next_id += 1;
107 + let id = self.next_id;
108 + let mut line =
109 + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
110 + line.push('\n');
111 + self.stdin.write_all(line.as_bytes()).unwrap();
112 + self.stdin.flush().unwrap();
113 + id
114 + }
115 +
116 + /// The response to `id` and every `session/update` seen on the way.
117 + fn finish(&mut self, id: u64) -> (Value, Vec<Value>) {
118 + let mut updates = Vec::new();
119 + let deadline = Instant::now() + TIMEOUT;
120 + loop {
121 + let remaining = deadline.saturating_duration_since(Instant::now());
122 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
123 + panic!("timed out waiting for the response to request {id}");
124 + };
125 + if message["method"] == "session/update" {
126 + updates.push(message["params"]["update"].clone());
127 + }
128 + if message["id"] == id && message.get("method").is_none() {
129 + assert!(
130 + message.get("error").is_none(),
131 + "request {id} failed: {message}"
132 + );
133 + return (message, updates);
134 + }
135 + }
136 + }
137 +
138 + fn next_commands_update(&mut self) -> Value {
139 + let deadline = Instant::now() + TIMEOUT;
140 + loop {
141 + let remaining = deadline.saturating_duration_since(Instant::now());
142 + let message = self
143 + .incoming
144 + .recv_timeout(remaining)
145 + .expect("timed out waiting for available_commands_update");
146 + let update = &message["params"]["update"];
147 + if message["method"] == "session/update"
148 + && update["sessionUpdate"] == "available_commands_update"
149 + {
150 + return update.clone();
151 + }
152 + }
153 + }
154 +}
155 +
156 +impl Drop for Wire {
157 + fn drop(&mut self) {
158 + let _ = self.child.kill();
159 + let _ = self.child.wait();
160 + }
161 +}
162 +
163 +#[test]
164 +fn a_skill_is_advertised_and_runs_as_a_slash_command() {
165 + let scratch = std::env::temp_dir().join(format!("sigit_acp_skill_cmd_{}", std::process::id()));
166 + let config_dir = scratch.join("config");
167 + let project = scratch.join("project");
168 + let skill_dir = config_dir.join("skills").join("zz-shelf-sorter");
169 + std::fs::create_dir_all(&skill_dir).unwrap();
170 + std::fs::create_dir_all(&project).unwrap();
171 + // A description past 100 characters, over two lines.
172 + let description = format!("Sort shelves.\n {}", "Use it when asked. ".repeat(10));
173 + std::fs::write(
174 + skill_dir.join("SKILL.md"),
175 + format!(
176 + "---\nname: zz-shelf-sorter\ndescription: \"{}\"\n---\n\nAlphabetise the shelves first.\n",
177 + description.replace('\n', " ")
178 + ),
179 + )
180 + .unwrap();
181 +
182 + let (port, requests) = start_fake_endpoint();
183 + let mut wire = spawn(port, &config_dir);
184 + let id = wire.request(
185 + "initialize",
186 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
187 + );
188 + wire.finish(id);
189 +
190 + let id = wire.request("session/new", json!({"cwd": project, "mcpServers": []}));
191 + let (response, _) = wire.finish(id);
192 + let session_id = response["result"]["sessionId"]
193 + .as_str()
194 + .unwrap()
195 + .to_string();
196 +
197 + let update = wire.next_commands_update();
198 + let commands = update["availableCommands"].as_array().unwrap();
199 + let skill = commands
200 + .iter()
201 + .find(|c| c["name"] == "zz-shelf-sorter")
202 + .unwrap_or_else(|| panic!("the skill must be advertised: {update}"));
203 + assert_eq!(skill["input"]["hint"], "task (optional)", "{skill}");
204 + let shown = skill["description"].as_str().unwrap();
205 + assert!(
206 + shown.chars().count() <= 100 && !shown.contains('\n'),
207 + "description must be one short line: {shown:?}"
208 + );
209 +
210 + let id = wire.request(
211 + "session/prompt",
212 + json!({
213 + "sessionId": session_id,
214 + "prompt": [{"type": "text", "text": "/zz-shelf-sorter do the garage"}],
215 + }),
216 + );
217 + wire.finish(id);
218 +
219 + let body = requests
220 + .recv_timeout(TIMEOUT)
221 + .expect("the skill command should run a turn against the model endpoint");
222 + assert!(body.contains("Alphabetise the shelves first."), "{body}");
223 + assert!(body.contains("Task: do the garage"), "{body}");
224 +
225 + drop(wire);
226 + let _ = std::fs::remove_dir_all(&scratch);
227 +}