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
+}