WIp

paydii committed Jul 10, 2026 at 20:54 UTC a7deedaac316f6a29d93913d1f15230e44dcfd5b
3 files changed +325 -4
src/main.rs
+1
@@ -42,6 +42,7 @@ mod session_store;
42 mod settings;
43 mod setup;
44 mod skills;
45 +mod subagents;
46 mod tools;
47
48 /// Serializes tests that mutate process-global env vars (`SIGIT_CONFIG_DIR`
src/skills.rs
+4 -4
@@ -332,7 +332,7 @@ fn parse_skill(contents: &str, dir: &Path) -> Result<Skill, String> {
332
333 /// Validate the `name` field per the Agent Skills spec: 1-64 chars, lowercase
334 /// alphanumeric and hyphens only, no leading/trailing or consecutive hyphens.
335 -fn validate_name(name: &str) -> Result<(), String> {
335 +pub(crate) fn validate_name(name: &str) -> Result<(), String> {
336 let len = name.chars().count();
337 if len == 0 {
338 return Err("`name` must not be empty".to_string());
@@ -357,7 +357,7 @@ fn validate_name(name: &str) -> Result<(), String> {
357
358 /// Extract the YAML frontmatter block from a `SKILL.md`: the text between a
359 /// leading `---` line and the next `---` line. Returns `None` if absent.
360 -fn extract_frontmatter(contents: &str) -> Option<&str> {
360 +pub(crate) fn extract_frontmatter(contents: &str) -> Option<&str> {
361 // Strip an optional UTF-8 BOM and leading blank lines before the opener.
362 let trimmed = contents.trim_start_matches('\u{feff}');
363 let mut rest = trimmed;
@@ -393,7 +393,7 @@ fn extract_frontmatter(contents: &str) -> Option<&str> {
393 /// Parse top-level `key: value` scalar pairs from frontmatter, skipping nested
394 /// mappings (indented lines) and comments. Quoted values are unquoted. We only
395 /// need scalar metadata (`name`, `description`, `license`, `compatibility`).
396 -fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {
396 +pub(crate) fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {
397 let mut fields = Vec::new();
398 for line in frontmatter.lines() {
399 // Indented lines belong to a nested mapping/sequence — skip them.
@@ -440,7 +440,7 @@ fn read_skill_body(skill_md: &Path) -> Result<String, String> {
440
441 /// Return the content after the frontmatter block, or the whole input if there
442 /// is no frontmatter.
443 -fn strip_frontmatter(contents: &str) -> &str {
443 +pub(crate) fn strip_frontmatter(contents: &str) -> &str {
444 let trimmed = contents.trim_start_matches('\u{feff}');
445 let after_opener = match trimmed.strip_prefix("---") {
446 Some(rest) => match rest.strip_prefix('\n') {
src/subagents.rs new
+320
@@ -0,0 +1,320 @@
1 +//! Configurable subagent types for the `task` tool.
2 +//!
3 +//! A subagent type is a Markdown file with YAML frontmatter:
4 +//!
5 +//! ```markdown
6 +//! ---
7 +//! name: code-reviewer
8 +//! description: Reviews a diff for correctness bugs. Use when the user asks for a review.
9 +//! tools: read_file, search_files, glob
10 +//! ---
11 +//!
12 +//! You are a meticulous code reviewer. Focus on correctness bugs first...
13 +//! ```
14 +//!
15 +//! Discovered from, in priority order (earlier wins on name clashes):
16 +//!
17 +//! - `<cwd>/.sigit/agents/` and `<cwd>/.claude/agents/` (project-local)
18 +//! - `$SIGIT_CONFIG_DIR/agents/` (default `~/.config/sigit/agents/`)
19 +//! - `~/.claude/agents/` (shared with the broader Claude Code ecosystem)
20 +//!
21 +//! `name` and `description` are required (same slug rules as
22 +//! [`crate::skills`]); the Markdown body becomes the subagent's system
23 +//! prompt, replacing [`crate::tools::SUBAGENT_SYSTEM_PROMPT`] for that run.
24 +//!
25 +//! `tools` is optional and, when present, is a comma-separated allow-list.
26 +//! It can only ever *narrow* the subagent's toolset — [`crate::tools`] owns
27 +//! the actual read-only allow-list ([`crate::tools::SUBAGENT_TOOL_NAMES`])
28 +//! and intersects it against this list, so a type file cannot grant itself
29 +//! `edit_file`, `run_command`, or any other mutating tool. That security
30 +//! boundary is why the intersection logic lives in `tools.rs` next to the
31 +//! constant it's protecting, not here — this module only discovers and
32 +//! parses files, it never decides what a subagent may execute.
33 +
34 +use std::path::{Path, PathBuf};
35 +
36 +use crate::skills::{extract_frontmatter, parse_frontmatter_fields, strip_frontmatter, validate_name};
37 +
38 +/// A discovered subagent type.
39 +#[derive(Debug, Clone, PartialEq, Eq)]
40 +pub struct AgentType {
41 + /// The `name` from frontmatter — the value passed as `task`'s
42 + /// `subagent_type` argument.
43 + pub name: String,
44 + /// The `description` from frontmatter: what the type is for and when to
45 + /// use it, embedded in the `task` tool's description.
46 + pub description: String,
47 + /// Raw `tools` list from frontmatter, if present. Unfiltered — see
48 + /// [`crate::tools`] for the security-relevant intersection against the
49 + /// base read-only allow-list.
50 + pub tools: Option<Vec<String>>,
51 + /// The Markdown body (frontmatter stripped): this type's system prompt.
52 + pub system_prompt: String,
53 + /// Where this type's file lives on disk, for diagnostics.
54 + pub path: PathBuf,
55 +}
56 +
57 +/// Discover all valid subagent types across the known roots. Earlier roots
58 +/// win when two types share a `name`. Sorted by name for stable output.
59 +pub fn discover_agent_types() -> Vec<AgentType> {
60 + let mut types: Vec<AgentType> = Vec::new();
61 + let mut seen_names: Vec<String> = Vec::new();
62 +
63 + for root in agent_type_roots() {
64 + collect_agent_types_from_root(&root, &mut types, &mut seen_names);
65 + }
66 +
67 + types.sort_by(|a, b| a.name.cmp(&b.name));
68 + types
69 +}
70 +
71 +/// Re-discover types and find one by name.
72 +pub fn resolve_agent_type(name: &str) -> Option<AgentType> {
73 + discover_agent_types().into_iter().find(|t| t.name == name)
74 +}
75 +
76 +/// Human-readable list of discovered subagent types, for the `/agents` slash
77 +/// command.
78 +pub fn format_agent_types_list() -> String {
79 + let types = discover_agent_types();
80 + if types.is_empty() {
81 + return "No subagent types found. Add a Markdown file under \
82 + .sigit/agents/ or .claude/agents/ in your project (e.g. \
83 + .sigit/agents/code-reviewer.md), or under \
84 + ~/.config/sigit/agents/. Without one, `task` uses the \
85 + default general-purpose research subagent."
86 + .to_string();
87 + }
88 +
89 + let mut lines = vec![format!("{} subagent type(s) available:", types.len())];
90 + for agent_type in &types {
91 + lines.push(format!("- {}: {}", agent_type.name, agent_type.description));
92 + }
93 + lines.push(String::new());
94 + lines.push(
95 + "Pass one of these names as `subagent_type` when calling `task`; omit it for the \
96 + default general-purpose research subagent."
97 + .to_string(),
98 + );
99 + lines.join("\n")
100 +}
101 +
102 +/// The subagent-type directories to scan, in priority order.
103 +fn agent_type_roots() -> Vec<PathBuf> {
104 + let mut roots = Vec::new();
105 +
106 + // Project-local types win over user-global ones.
107 + if let Ok(cwd) = std::env::current_dir() {
108 + roots.push(cwd.join(".sigit").join("agents"));
109 + roots.push(cwd.join(".claude").join("agents"));
110 + }
111 +
112 + // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
113 + if let Some(config_dir) = sigit_config_dir() {
114 + roots.push(config_dir.join("agents"));
115 + }
116 +
117 + // Shared with the broader Claude Code ecosystem.
118 + if let Some(home) = home_dir() {
119 + roots.push(home.join(".claude").join("agents"));
120 + }
121 +
122 + roots
123 +}
124 +
125 +fn sigit_config_dir() -> Option<PathBuf> {
126 + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
127 + && !dir.is_empty()
128 + {
129 + return Some(PathBuf::from(dir));
130 + }
131 + home_dir().map(|home| home.join(".config").join("sigit"))
132 +}
133 +
134 +fn home_dir() -> Option<PathBuf> {
135 + std::env::var("HOME").ok().map(PathBuf::from)
136 +}
137 +
138 +/// Scan a single root (non-recursive, matching [`crate::skills`]) for `*.md`
139 +/// files, appending newly-seen types.
140 +fn collect_agent_types_from_root(
141 + root: &Path,
142 + types: &mut Vec<AgentType>,
143 + seen_names: &mut Vec<String>,
144 +) {
145 + let entries = match std::fs::read_dir(root) {
146 + Ok(entries) => entries,
147 + // Most roots won't exist; that's expected, not an error.
148 + Err(_) => return,
149 + };
150 +
151 + let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
152 + paths.sort();
153 +
154 + for path in paths {
155 + if path.extension().and_then(|e| e.to_str()) != Some("md") {
156 + continue;
157 + }
158 +
159 + let contents = match std::fs::read_to_string(&path) {
160 + Ok(contents) => contents,
161 + Err(error) => {
162 + log::warn!("skipping agent type at {}: {error}", path.display());
163 + continue;
164 + }
165 + };
166 +
167 + let agent_type = match parse_agent_type(&contents, &path) {
168 + Ok(agent_type) => agent_type,
169 + Err(error) => {
170 + log::warn!("skipping invalid agent type at {}: {error}", path.display());
171 + continue;
172 + }
173 + };
174 +
175 + if seen_names.iter().any(|n| n == &agent_type.name) {
176 + log::debug!(
177 + "agent type \"{}\" at {} shadowed by an earlier definition",
178 + agent_type.name,
179 + path.display()
180 + );
181 + continue;
182 + }
183 +
184 + seen_names.push(agent_type.name.clone());
185 + types.push(agent_type);
186 + }
187 +}
188 +
189 +/// Parse a subagent-type file into an [`AgentType`]. `name` and
190 +/// `description` are required, mirroring [`crate::skills`]'s `SKILL.md`
191 +/// validation.
192 +fn parse_agent_type(contents: &str, path: &Path) -> Result<AgentType, String> {
193 + let frontmatter = extract_frontmatter(contents)
194 + .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
195 +
196 + let fields = parse_frontmatter_fields(frontmatter);
197 +
198 + let name = fields
199 + .iter()
200 + .find(|(k, _)| k == "name")
201 + .map(|(_, v)| v.clone())
202 + .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
203 + validate_name(&name)?;
204 +
205 + let description = fields
206 + .iter()
207 + .find(|(k, _)| k == "description")
208 + .map(|(_, v)| v.clone())
209 + .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
210 + if description.is_empty() {
211 + return Err("`description` must not be empty".to_string());
212 + }
213 +
214 + let tools = fields
215 + .iter()
216 + .find(|(k, _)| k == "tools")
217 + .map(|(_, v)| v.clone())
218 + .filter(|v| !v.is_empty())
219 + .map(|raw| {
220 + raw.split(',')
221 + .map(|t| t.trim().to_string())
222 + .filter(|t| !t.is_empty())
223 + .collect::<Vec<String>>()
224 + });
225 +
226 + let system_prompt = strip_frontmatter(contents).trim().to_string();
227 + if system_prompt.is_empty() {
228 + return Err("body (the system prompt) must not be empty".to_string());
229 + }
230 +
231 + Ok(AgentType {
232 + name,
233 + description,
234 + tools,
235 + system_prompt,
236 + path: path.to_path_buf(),
237 + })
238 +}
239 +
240 +#[cfg(test)]
241 +mod tests {
242 + use super::*;
243 + use std::fs;
244 +
245 + fn unique_dir(name: &str) -> PathBuf {
246 + let nanos = std::time::SystemTime::now()
247 + .duration_since(std::time::UNIX_EPOCH)
248 + .unwrap()
249 + .as_nanos();
250 + std::env::temp_dir().join(format!("sigit-subagents-test-{name}-{nanos}"))
251 + }
252 +
253 + #[test]
254 + fn parse_agent_type_reads_frontmatter_and_body() {
255 + let contents = "---\nname: code-reviewer\ndescription: Reviews diffs\ntools: read_file, search_files\n---\n\nYou are a reviewer.\n";
256 + let agent_type = parse_agent_type(contents, Path::new("/x/code-reviewer.md")).unwrap();
257 + assert_eq!(agent_type.name, "code-reviewer");
258 + assert_eq!(agent_type.description, "Reviews diffs");
259 + assert_eq!(
260 + agent_type.tools,
261 + Some(vec!["read_file".to_string(), "search_files".to_string()])
262 + );
263 + assert_eq!(agent_type.system_prompt, "You are a reviewer.");
264 + }
265 +
266 + #[test]
267 + fn parse_agent_type_without_tools_field_is_none() {
268 + let contents = "---\nname: generalist\ndescription: does anything\n---\n\nBe helpful.\n";
269 + let agent_type = parse_agent_type(contents, Path::new("/x/generalist.md")).unwrap();
270 + assert!(agent_type.tools.is_none());
271 + }
272 +
273 + #[test]
274 + fn parse_agent_type_requires_name_description_and_body() {
275 + let dir = Path::new("/tmp/x.md");
276 + assert!(parse_agent_type("---\ndescription: x\n---\nbody", dir).is_err());
277 + assert!(parse_agent_type("---\nname: x\n---\nbody", dir).is_err());
278 + assert!(parse_agent_type("---\nname: x\ndescription: y\n---\n\n", dir).is_err());
279 + assert!(parse_agent_type("---\nname: Bad Name\ndescription: y\n---\nbody", dir).is_err());
280 + }
281 +
282 + #[test]
283 + fn discover_and_resolve_roundtrip() {
284 + let root = unique_dir("roundtrip");
285 + let agents_root = root.join(".sigit").join("agents");
286 + fs::create_dir_all(&agents_root).unwrap();
287 + fs::write(
288 + agents_root.join("code-reviewer.md"),
289 + "---\nname: code-reviewer\ndescription: Reviews diffs\ntools: read_file, search_files\n---\n\nYou are a reviewer.\n",
290 + )
291 + .unwrap();
292 +
293 + let prev = std::env::current_dir().unwrap();
294 + std::env::set_current_dir(&root).unwrap();
295 +
296 + let types = discover_agent_types();
297 + assert_eq!(types.len(), 1);
298 + assert_eq!(types[0].name, "code-reviewer");
299 +
300 + let resolved = resolve_agent_type("code-reviewer").expect("resolve by name");
301 + assert_eq!(resolved.system_prompt, "You are a reviewer.");
302 + assert!(resolve_agent_type("nope").is_none());
303 +
304 + std::env::set_current_dir(prev).unwrap();
305 + let _ = fs::remove_dir_all(&root);
306 + }
307 +
308 + #[test]
309 + fn format_agent_types_list_reports_none_found() {
310 + let root = unique_dir("empty");
311 + fs::create_dir_all(&root).unwrap();
312 + let prev = std::env::current_dir().unwrap();
313 + std::env::set_current_dir(&root).unwrap();
314 +
315 + assert!(format_agent_types_list().contains("No subagent types found"));
316 +
317 + std::env::set_current_dir(prev).unwrap();
318 + let _ = fs::remove_dir_all(&root);
319 + }
320 +}