@sigitcodereview-old / sigit / commits / a0d09a6
Fork 0

A modular local inference LLM coding agent. Core component of siGit Factory.

Add Agent Skills and project instruction file support

Implements the open Agent Skills format (agentskills.io). siGit now discovers skill folders (each with a SKILL.md) from .sigit/skills and .claude/skills in the project, ~/.config/sigit/skills, and ~/.claude/skills. Discovery loads only each skill's name and description into a new `skill` tool; activating a skill loads the full SKILL.md on demand, which follows the spec's progressive disclosure. A /skills command lists what is available in both the TUI and ACP. Also adds project instruction files, the always-on counterpart to skills. At session start siGit reads AGENTS.md (the agents.md standard) and CLAUDE.md, walking from the working directory up to the repo root (never above it) plus a global file under ~/.config/sigit, and injects them into the session's system context. Nested files are ordered outermost-first so the closest one wins. Covered by 16 new tests. fmt, clippy with -D warnings, and the full suite of 78 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 29, 2026 at 20:41 UTC a0d09a6e3a62f81cd898f0497685ea85308a08a2
9 files changed +1076 -47
CHANGELOG.md
+9 -1
@@ -2,9 +2,17 @@
2
3 ## Unreleased
4
5 +Adds support for the open [Agent Skills](https://agentskills.io) format and for
6 +project instruction files (`AGENTS.md` and the like).
7 +
8 ### What changed
9
7 -- On-device models are no longer loaded implicitly. The chat UI and ACP sessions come up immediately, and the local model is brought into memory only when you run the new `/load` command (or pick one in `/models`). Prompts sent before a model is loaded now return a hint instead of blocking on a multi-minute download.
10 +- Discovers Agent Skills (folders with a `SKILL.md`) from `.sigit/skills/` and `.claude/skills/` in the project, `~/.config/sigit/skills/`, and `~/.claude/skills/`
11 +- Follows the spec's progressive disclosure: each skill's name and description are advertised up front via a new `skill` tool, and the full instructions load only when the agent activates one
12 +- Added a `/skills` slash command (TUI and ACP) that lists the discovered skills
13 +- Reads project instruction files at session start: `AGENTS.md` (the cross-tool standard) and `CLAUDE.md`, walking from the working directory up to the repository root, plus a global file under `~/.config/sigit/`, and injects them into the session's system context so their guidance is always in force
14 +- Nested instruction files are ordered outermost-first so the closest, most specific file takes precedence; the scan never reads above the repository root
15 +- On-device models are no longer loaded implicitly. The chat UI and ACP sessions come up immediately, and the local model is brought into memory only when you run the `/load` command (or pick one in `/models`). Prompts sent before a model is loaded now return a hint instead of blocking on a multi-minute download.
16
17 ## 1.2.2
18
CLAUDE.md
+18 -3
@@ -64,6 +64,21 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
64 - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
65 `list_directory`, `search_files`, `read_website`, `create_file`, `edit_file`, `delete_file`,
66 `run_command`. Add a tool in both the spec list and the execute `match`.
67 +- **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
68 + folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
69 + instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
70 + and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is
71 + baked into the dynamically-built `skill` tool's description, and activating a skill (the model
72 + calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
73 + `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
74 + and only when at least one skill exists.
75 +- **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
76 + Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
77 + walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
78 + plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest
79 + (most specific) wins. The combined block is injected via `session_context_message` in `main.rs`
80 + — pushed as a system message at every ACP session entry point (new/load/fork + model switch)
81 + and appended to the system prompt on the cloud and TUI-startup paths.
82 - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
83 `tokio::select!` to multiplex terminal events with streaming tokens.
84 - **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
@@ -74,9 +89,9 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
89 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
90 - **`src/models.rs`** — model-picker types shared across platforms.
91
77 -Slash commands (`/help`, `/models`, `/login`, `/logout`, `/whoami`, `/reload`, `/clear`,
78 -`/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the TUI and
79 -ACP sessions.
92 +Slash commands (`/help`, `/models`, `/skills`, `/login`, `/logout`, `/whoami`, `/reload`,
93 +`/clear`, `/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the
94 +TUI and ACP sessions.
95
96 ## Model cache (macOS)
97
examples/skills/README.md new
+36
@@ -0,0 +1,36 @@
1 +# Example Agent Skills
2 +
3 +siGit Code supports the open [Agent Skills](https://agentskills.io) format. A
4 +skill is a folder containing a `SKILL.md` file — YAML frontmatter (`name` and
5 +`description`, at minimum) followed by Markdown instructions. Skills can bundle
6 +`scripts/`, `references/`, and `assets/` that the agent reads on demand.
7 +
8 +## Installing a skill
9 +
10 +Copy a skill folder into one of the directories siGit scans (in priority order):
11 +
12 +- `.sigit/skills/` or `.claude/skills/` in your project (project-local)
13 +- `~/.config/sigit/skills/` (honours `$SIGIT_CONFIG_DIR`)
14 +- `~/.claude/skills/` (shared with the broader ecosystem)
15 +
16 +For example, to install the `commit-message` skill here for the current project:
17 +
18 +```sh
19 +mkdir -p .sigit/skills
20 +cp -R examples/skills/commit-message .sigit/skills/
21 +```
22 +
23 +The folder name must match the skill's `name` field.
24 +
25 +## How siGit uses them
26 +
27 +siGit follows the spec's *progressive disclosure*:
28 +
29 +1. **Discovery** — at the start of each turn, siGit loads only each skill's
30 + `name` and `description` into the `skill` tool's description.
31 +2. **Activation** — when your task matches a skill, the agent calls the `skill`
32 + tool with that name, which loads the full `SKILL.md` into context.
33 +3. **Execution** — the agent follows the instructions, reading any bundled files
34 + from the skill's directory with its normal file and command tools.
35 +
36 +Run `/skills` to list the skills siGit can see.
examples/skills/commit-message/SKILL.md new
+44
@@ -0,0 +1,44 @@
1 +---
2 +name: commit-message
3 +description: Write a clear git commit message from staged changes. Use when the user asks to commit, write a commit message, or describe staged changes.
4 +license: Apache-2.0
5 +metadata:
6 + author: sigit
7 + version: "1.0"
8 +---
9 +
10 +# Commit message
11 +
12 +Write a concise, conventional commit message that describes *why* a change was
13 +made, not just what changed.
14 +
15 +## Steps
16 +
17 +1. Inspect what is staged: run `git diff --cached` (and `git status` for context).
18 +2. Group the changes into a single logical intent. If they span unrelated
19 + concerns, say so and suggest splitting the commit.
20 +3. Write the message:
21 + - **Subject line**: imperative mood, lowercase after the type, no trailing
22 + period, ≤ 50 characters. Prefix with a type when it fits the repo's
23 + convention (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`).
24 + - **Body** (optional): wrap at 72 columns. Explain the motivation and any
25 + non-obvious tradeoffs. Reference issues if relevant.
26 +4. Show the message to the user before committing. Only run `git commit` if they
27 + confirm.
28 +
29 +## Examples
30 +
31 +Good subject lines:
32 +
33 +```
34 +fix: stop the picker from reloading a working model on /reload
35 +refactor: extract skill discovery into its own module
36 +```
37 +
38 +Avoid:
39 +
40 +```
41 +Update files
42 +fixed bug
43 +WIP
44 +```
src/chat.rs
+38 -4
@@ -662,6 +662,8 @@ mod tui {
662 Status,
663 /// picker UI, or jump straight to model N
664 Models(Option<usize>),
665 + /// List discovered Agent Skills.
666 + Skills,
667 /// explicitly load the selected (or default) on-device model
668 Load,
669 /// `/login <email> <password>` — the raw argument, parsed when executed.
@@ -685,6 +687,7 @@ mod tui {
687 "/clear" => SlashCommand::Clear,
688 "/status" => SlashCommand::Status,
689 "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
690 + "/skills" => SlashCommand::Skills,
691 "/load" => SlashCommand::Load,
692 "/login" => SlashCommand::Login(arg.map(str::to_string)),
693 "/logout" => SlashCommand::Logout,
@@ -1214,8 +1217,20 @@ mod tui {
1217 ..SamplingConfig::default()
1218 };
1219
1217 - // own thread + runtime so block_in_place doesn't starve the TUI loop
1218 - let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1220 + // own thread + runtime so block_in_place doesn't starve the TUI loop.
1221 + // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch
1222 + // directory so the on-device model gets the same always-on context the
1223 + // cloud and ACP paths get.
1224 + let system_prompt = {
1225 + let base = crate::system_prompt_for_model(model.tool_calling).to_string();
1226 + match std::env::current_dir()
1227 + .ok()
1228 + .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
1229 + {
1230 + Some(extra) => format!("{base}\n\n{extra}"),
1231 + None => base,
1232 + }
1233 + };
1234 let engine_handle = Arc::clone(&engine);
1235 let tool_calling = model.tool_calling;
1236 std::thread::spawn(move || {
@@ -1254,6 +1269,7 @@ mod tui {
1269 "/help — show this message\n\
1270 /models — open the model picker\n\
1271 /models N — switch to model N\n\
1272 + /skills — list available Agent Skills\n\
1273 /load — load the selected on-device model\n\
1274 /login E P — sign in to siGit Code Cloud\n\
1275 /logout — sign out\n\
@@ -1279,6 +1295,10 @@ mod tui {
1295 info.status, model, mem, info.history_length,
1296 )));
1297 }
1298 + SlashCommand::Skills => {
1299 + app.messages
1300 + .push(ChatMessage::system(crate::skills::format_skills_list()));
1301 + }
1302 SlashCommand::Models(selection) => match selection {
1303 None => {
1304 app.open_model_picker(&engine);
@@ -1376,14 +1396,28 @@ mod tui {
1396 const MAX_TOOL_ROUNDS: usize = 10;
1397
1398 fn build_tool_specs() -> Vec<ToolSpec> {
1379 - crate::tools::all_tools()
1399 + let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
1400 .into_iter()
1401 .map(|t| ToolSpec {
1402 name: t.name.to_string(),
1403 description: t.description.to_string(),
1404 parameters_schema: t.parameters_schema.to_string(),
1405 })
1386 - .collect()
1406 + .collect();
1407 +
1408 + // Advertise the Agent Skills `skill` tool only when skills exist on disk
1409 + // (https://agentskills.io). The tool description carries the discovery
1410 + // list (name + description) for progressive disclosure.
1411 + let discovered = crate::skills::discover_skills();
1412 + if !discovered.is_empty() {
1413 + specs.push(ToolSpec {
1414 + name: crate::skills::SKILL_TOOL_NAME.to_string(),
1415 + description: crate::skills::skill_tool_description(&discovered),
1416 + parameters_schema: crate::skills::skill_tool_schema().to_string(),
1417 + });
1418 + }
1419 +
1420 + specs
1421 }
1422
1423 /// run the tool-calling loop off the main thread, posting updates via `tx`.
src/instructions.rs new
+241
@@ -0,0 +1,241 @@
1 +//! Project instruction files (`AGENTS.md` and the like).
2 +//!
3 +//! Agentic coding tools converge on a convention: a Markdown file checked into a
4 +//! project that carries always-on, project-specific guidance for the agent. The
5 +//! cross-tool open standard is [`AGENTS.md`](https://agents.md); siGit also reads
6 +//! `CLAUDE.md` for compatibility with the wider ecosystem.
7 +//!
8 +//! This is the always-on counterpart to Agent Skills (`skills.rs`): skills load
9 +//! *on demand* when a task matches, whereas instruction files load *once per
10 +//! session* and are injected into the system context so their guidance is always
11 +//! in force.
12 +//!
13 +//! Discovery walks from the session's working directory up to the repository
14 +//! root (the nearest ancestor containing `.git`), reading one instruction file
15 +//! per directory. A global file under `$SIGIT_CONFIG_DIR` (default
16 +//! `~/.config/sigit/`) is included with the lowest precedence. Files are ordered
17 +//! outermost-first (global, then repo root … down to the cwd) so that more
18 +//! specific, deeper files are read last and take precedence — matching the
19 +//! `AGENTS.md` convention.
20 +
21 +use std::path::{Path, PathBuf};
22 +
23 +/// Instruction file names to look for in each directory, in priority order.
24 +/// Only the first match in a given directory is loaded.
25 +const INSTRUCTION_FILE_NAMES: &[&str] = &["AGENTS.md", "CLAUDE.md"];
26 +
27 +/// Per-file and total caps so an oversized file can't blow up the context window.
28 +const MAX_FILE_BYTES: usize = 32 * 1024;
29 +const MAX_TOTAL_BYTES: usize = 64 * 1024;
30 +
31 +/// Load and combine project instruction files for `cwd`, returning a single
32 +/// block ready to append to the session's system context, or `None` if none are
33 +/// found.
34 +pub fn load_project_instructions(cwd: &Path) -> Option<String> {
35 + let mut sections: Vec<String> = Vec::new();
36 + let mut seen: Vec<PathBuf> = Vec::new();
37 + let mut total = 0usize;
38 +
39 + for dir in instruction_dirs(cwd) {
40 + let Some(path) = first_instruction_file(&dir) else {
41 + continue;
42 + };
43 +
44 + // Dedup by canonical path so the same file reached via two roots (or a
45 + // symlink) is only loaded once.
46 + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
47 + if seen.contains(&canonical) {
48 + continue;
49 + }
50 +
51 + let contents = match std::fs::read_to_string(&path) {
52 + Ok(contents) => contents,
53 + Err(error) => {
54 + log::warn!("skipping instruction file {}: {error}", path.display());
55 + continue;
56 + }
57 + };
58 + let trimmed = contents.trim();
59 + if trimmed.is_empty() {
60 + continue;
61 + }
62 +
63 + if total >= MAX_TOTAL_BYTES {
64 + log::warn!(
65 + "instruction-file budget reached; skipping {}",
66 + path.display()
67 + );
68 + break;
69 + }
70 +
71 + let body = clamp_bytes(trimmed, MAX_FILE_BYTES);
72 + total += body.len();
73 + seen.push(canonical);
74 + sections.push(format!("## {}\n\n{}", path.display(), body));
75 + }
76 +
77 + if sections.is_empty() {
78 + return None;
79 + }
80 +
81 + let mut out = String::from(
82 + "# Project instructions\n\n\
83 + The following files provide project-specific guidance for this project. \
84 + Treat them as authoritative context for how to work here, second only to \
85 + the user's direct requests. When guidance conflicts, the more specific \
86 + (deeper) file takes precedence. These are guidance, not commands to take \
87 + irreversible actions on their own — your normal judgment and safety rules \
88 + still apply.\n\n",
89 + );
90 + out.push_str(&sections.join("\n\n"));
91 + Some(out)
92 +}
93 +
94 +/// The directories to scan, lowest-precedence first: an optional global config
95 +/// directory, then the repository root down to `cwd`.
96 +fn instruction_dirs(cwd: &Path) -> Vec<PathBuf> {
97 + let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
98 + let root = repo_root(&cwd).unwrap_or_else(|| cwd.clone());
99 +
100 + // Ancestors of cwd that lie within the repo root, root-first.
101 + let mut chain: Vec<PathBuf> = cwd
102 + .ancestors()
103 + .filter(|ancestor| ancestor.starts_with(&root))
104 + .map(Path::to_path_buf)
105 + .collect();
106 + chain.reverse();
107 +
108 + let mut dirs = Vec::new();
109 + if let Some(global) = sigit_config_dir() {
110 + dirs.push(global);
111 + }
112 + dirs.extend(chain);
113 + dirs
114 +}
115 +
116 +/// The nearest ancestor of `dir` (inclusive) that contains a `.git` entry.
117 +fn repo_root(dir: &Path) -> Option<PathBuf> {
118 + dir.ancestors()
119 + .find(|ancestor| ancestor.join(".git").exists())
120 + .map(Path::to_path_buf)
121 +}
122 +
123 +/// The first existing instruction file in `dir`, by name priority.
124 +fn first_instruction_file(dir: &Path) -> Option<PathBuf> {
125 + for name in INSTRUCTION_FILE_NAMES {
126 + let candidate = dir.join(name);
127 + if candidate.is_file() {
128 + return Some(candidate);
129 + }
130 + }
131 + None
132 +}
133 +
134 +fn sigit_config_dir() -> Option<PathBuf> {
135 + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
136 + && !dir.is_empty()
137 + {
138 + return Some(PathBuf::from(dir));
139 + }
140 + std::env::var("HOME")
141 + .ok()
142 + .map(|home| PathBuf::from(home).join(".config").join("sigit"))
143 +}
144 +
145 +/// Truncate `text` to at most `limit` bytes on a char boundary, appending a
146 +/// marker when truncation happens.
147 +fn clamp_bytes(text: &str, limit: usize) -> String {
148 + if text.len() <= limit {
149 + return text.to_string();
150 + }
151 + let mut end = limit;
152 + while end > 0 && !text.is_char_boundary(end) {
153 + end -= 1;
154 + }
155 + format!(
156 + "{}\n\n--- truncated ({} of {} bytes shown) ---",
157 + &text[..end],
158 + end,
159 + text.len()
160 + )
161 +}
162 +
163 +#[cfg(test)]
164 +mod tests {
165 + use super::*;
166 + use std::fs;
167 +
168 + fn unique_dir(name: &str) -> PathBuf {
169 + let nanos = std::time::SystemTime::now()
170 + .duration_since(std::time::UNIX_EPOCH)
171 + .unwrap()
172 + .as_nanos();
173 + std::env::temp_dir().join(format!("sigit-instr-test-{name}-{nanos}"))
174 + }
175 +
176 + #[test]
177 + fn none_when_no_files() {
178 + let root = unique_dir("empty");
179 + fs::create_dir_all(&root).unwrap();
180 + // Mark as a repo root so the scan doesn't escape into real ancestors.
181 + fs::create_dir_all(root.join(".git")).unwrap();
182 + assert!(load_project_instructions(&root).is_none());
183 + let _ = fs::remove_dir_all(&root);
184 + }
185 +
186 + #[test]
187 + fn agents_md_preferred_over_claude_md_in_same_dir() {
188 + let root = unique_dir("prefer");
189 + fs::create_dir_all(root.join(".git")).unwrap();
190 + fs::write(root.join("AGENTS.md"), "use tabs").unwrap();
191 + fs::write(root.join("CLAUDE.md"), "use spaces").unwrap();
192 +
193 + let out = load_project_instructions(&root).expect("instructions");
194 + assert!(out.contains("use tabs"));
195 + assert!(!out.contains("use spaces"));
196 + let _ = fs::remove_dir_all(&root);
197 + }
198 +
199 + #[test]
200 + fn nested_files_ordered_root_first() {
201 + let root = unique_dir("nested");
202 + let sub = root.join("crate-a");
203 + fs::create_dir_all(&sub).unwrap();
204 + fs::create_dir_all(root.join(".git")).unwrap();
205 + fs::write(root.join("AGENTS.md"), "ROOT RULES").unwrap();
206 + fs::write(sub.join("AGENTS.md"), "SUB RULES").unwrap();
207 +
208 + let out = load_project_instructions(&sub).expect("instructions");
209 + let root_pos = out.find("ROOT RULES").expect("root present");
210 + let sub_pos = out.find("SUB RULES").expect("sub present");
211 + // Root (broader) is read before the deeper, more specific file.
212 + assert!(root_pos < sub_pos, "root should precede sub:\n{out}");
213 + let _ = fs::remove_dir_all(&root);
214 + }
215 +
216 + #[test]
217 + fn does_not_escape_repo_root() {
218 + // A parent dir's AGENTS.md must not be read when the repo root is deeper.
219 + let root = unique_dir("boundary");
220 + let repo = root.join("repo");
221 + fs::create_dir_all(repo.join(".git")).unwrap();
222 + fs::write(root.join("AGENTS.md"), "OUTSIDE").unwrap();
223 + fs::write(repo.join("AGENTS.md"), "INSIDE").unwrap();
224 +
225 + let out = load_project_instructions(&repo).expect("instructions");
226 + assert!(out.contains("INSIDE"));
227 + assert!(
228 + !out.contains("OUTSIDE"),
229 + "must not read above repo root:\n{out}"
230 + );
231 + let _ = fs::remove_dir_all(&root);
232 + }
233 +
234 + #[test]
235 + fn clamp_bytes_truncates_long_input() {
236 + let long = "x".repeat(MAX_FILE_BYTES + 100);
237 + let clamped = clamp_bytes(&long, MAX_FILE_BYTES);
238 + assert!(clamped.contains("truncated"));
239 + assert!(clamped.len() < long.len() + 100);
240 + }
241 +}
src/main.rs
+76 -39
@@ -32,9 +32,11 @@ mod account;
32 mod backend;
33 mod chat;
34 mod credentials;
35 +mod instructions;
36 mod models;
37 mod provider;
38 mod setup;
39 +mod skills;
40 mod tools;
41
42 use std::io::IsTerminal;
@@ -216,15 +218,48 @@ const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in wit
218 `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
219 Create an account at https://sigit.si.";
220
221 +/// The per-session context system message: cwd guidance plus any project
222 +/// instruction files (`AGENTS.md` / `CLAUDE.md`) found for that directory. Used
223 +/// by every session entry point so on-device and cloud backends get the same
224 +/// always-on project context.
225 +fn session_context_message(cwd: &std::path::Path) -> String {
226 + let mut message = format!(
227 + "The user's project working directory is {}. \
228 + Always use absolute paths under this directory for all file \
229 + and directory operations. This is the root of the project \
230 + the user has open in their editor.",
231 + cwd.display()
232 + );
233 + if let Some(project_instructions) = instructions::load_project_instructions(cwd) {
234 + message.push_str("\n\n");
235 + message.push_str(&project_instructions);
236 + }
237 + message
238 +}
239 +
240 fn agent_tools_as_specs() -> Vec<ToolSpec> {
220 - tools::all_tools()
241 + let mut specs: Vec<ToolSpec> = tools::all_tools()
242 .into_iter()
243 .map(|t| ToolSpec {
244 name: t.name.to_string(),
245 description: t.description.to_string(),
246 parameters_schema: t.parameters_schema.to_string(),
247 })
227 - .collect()
248 + .collect();
249 +
250 + // Advertise the `skill` tool only when skills are present, so models without
251 + // any skills installed don't see a dangling capability (Agent Skills format,
252 + // https://agentskills.io). Discovery metadata lives in the tool description.
253 + let discovered = skills::discover_skills();
254 + if !discovered.is_empty() {
255 + specs.push(ToolSpec {
256 + name: skills::SKILL_TOOL_NAME.to_string(),
257 + description: skills::skill_tool_description(&discovered),
258 + parameters_schema: skills::skill_tool_schema().to_string(),
259 + });
260 + }
261 +
262 + specs
263 }
264
265 fn initialize_meta() -> Meta {
@@ -660,6 +695,7 @@ impl SiGitAgent {
695 "model number to switch to (optional)",
696 )),
697 ),
698 + AvailableCommand::new("skills", "List available Agent Skills"),
699 AvailableCommand::new("load", "Load the selected on-device model"),
700 with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"),
701 AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
@@ -755,13 +791,9 @@ impl SiGitAgent {
791
792 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
793 self.engine
758 - .push_history(onde::inference::ChatMessage::system(format!(
759 - "The user's project working directory is {}. \
760 - Always use absolute paths under this directory for all file \
761 - and directory operations. This is the root of the project \
762 - the user has open in their editor.",
763 - cwd.display()
764 - )))
794 + .push_history(onde::inference::ChatMessage::system(
795 + session_context_message(&cwd),
796 + ))
797 .await;
798 }
799
@@ -860,13 +892,9 @@ impl SiGitAgent {
892 self.engine.clear_history().await;
893
894 self.engine
863 - .push_history(onde::inference::ChatMessage::system(format!(
864 - "The user's project working directory is {}. \
865 - Always use absolute paths under this directory for all file \
866 - and directory operations. This is the root of the project \
867 - the user has open in their editor.",
868 - args.cwd.display()
869 - )))
895 + .push_history(onde::inference::ChatMessage::system(
896 + session_context_message(&args.cwd),
897 + ))
898 .await;
899
900 let config_options = {
@@ -908,13 +936,9 @@ impl SiGitAgent {
936 self.engine.clear_history().await;
937
938 self.engine
911 - .push_history(onde::inference::ChatMessage::system(format!(
912 - "The user's project working directory is {}. \
913 - Always use absolute paths under this directory for all file \
914 - and directory operations. This is the root of the project \
915 - the user has open in their editor.",
916 - args.cwd.display()
917 - )))
939 + .push_history(onde::inference::ChatMessage::system(
940 + session_context_message(&args.cwd),
941 + ))
942 .await;
943
944 let config_options = {
@@ -954,13 +978,9 @@ impl SiGitAgent {
978 self.engine.clear_history().await;
979
980 self.engine
957 - .push_history(onde::inference::ChatMessage::system(format!(
958 - "The user's project working directory is {}. \
959 - Always use absolute paths under this directory for all file \
960 - and directory operations. This is the root of the project \
961 - the user has open in their editor.",
962 - args.cwd.display()
963 - )))
981 + .push_history(onde::inference::ChatMessage::system(
982 + session_context_message(&args.cwd),
983 + ))
984 .await;
985
986 let config_options = {
@@ -1280,15 +1300,11 @@ impl SiGitAgent {
1300 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
1301 let cfg = crate::provider::cloud_tier_provider(tier)?;
1302 let mut system_prompt = system_prompt_for_model(true).to_string();
1283 - // Mirror the cwd guidance the local engine gets at session load, so the
1284 - // cloud model also uses absolute paths under the editor's project root.
1303 + // Mirror the cwd guidance and project instruction files the local engine
1304 + // gets at session load, so the cloud model shares the same project context.
1305 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1286 - system_prompt.push_str(&format!(
1287 - "\n\nThe user's project working directory is {}. \
1288 - Always use absolute paths under this directory for all file \
1289 - and directory operations.",
1290 - cwd.display()
1291 - ));
1306 + system_prompt.push_str("\n\n");
1307 + system_prompt.push_str(&session_context_message(&cwd));
1308 }
1309 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
1310 cfg.base_url,
@@ -1813,6 +1829,8 @@ enum SlashCommand {
1829 Clear,
1830 Status,
1831 Models(Option<usize>),
1832 + /// List discovered Agent Skills.
1833 + Skills,
1834 /// Explicitly load the selected (or default) on-device model.
1835 Load,
1836 /// `/login <email> <password>` — the raw argument, parsed when executed.
@@ -1838,6 +1856,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
1856 "/clear" => SlashCommand::Clear,
1857 "/status" => SlashCommand::Status,
1858 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
1859 + "/skills" => SlashCommand::Skills,
1860 "/load" => SlashCommand::Load,
1861 "/login" => SlashCommand::Login(argument.map(str::to_string)),
1862 "/logout" => SlashCommand::Logout,
@@ -1933,6 +1952,7 @@ async fn exec_slash_acp(
1952 "/help - show this message\n\
1953 /models - list available models\n\
1954 /models N - switch to model N\n\
1955 + /skills - list available Agent Skills\n\
1956 /load - load the selected on-device model\n\
1957 /login E P - sign in to siGit Code Cloud\n\
1958 /logout - sign out\n\
@@ -1975,6 +1995,11 @@ async fn exec_slash_acp(
1995 .send_assistant_message(cx, session_id, format_models_list(&current_model))
1996 .ok();
1997 }
1998 + SlashCommand::Skills => {
1999 + agent
2000 + .send_assistant_message(cx, session_id, skills::format_skills_list())
2001 + .ok();
2002 + }
2003 SlashCommand::Models(Some(number)) => {
2004 let items = models::build_model_picker_items();
2005 let index = number.saturating_sub(1);
@@ -2263,6 +2288,17 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
2288 // channel so the loading-phase plumbing in `chat::run_with` is unchanged.
2289 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
2290
2291 + // Project instruction files (AGENTS.md / CLAUDE.md) for the launch directory,
2292 + // injected into the system prompt so the TUI shares the same always-on
2293 + // project context the ACP sessions get.
2294 + let project_instructions = std::env::current_dir()
2295 + .ok()
2296 + .and_then(|cwd| instructions::load_project_instructions(&cwd));
2297 + let with_instructions = |base: String| match &project_instructions {
2298 + Some(extra) => format!("{base}\n\n{extra}"),
2299 + None => base,
2300 + };
2301 +
2302 // Pick the inference backend: a configured provider if present, else on-device.
2303 let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) =
2304 match provider::active_provider() {
@@ -2280,7 +2316,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
2316 provider.base_url,
2317 provider.api_key,
2318 provider.model,
2283 - Some(SYSTEM_PROMPT.to_string()),
2319 + Some(with_instructions(SYSTEM_PROMPT.to_string())),
2320 )) as Arc<dyn InferenceBackend>;
2321 (backend, label)
2322 }
@@ -2288,6 +2324,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
2324 // On-device: do NOT load the local GGUF model implicitly. The user
2325 // loads it explicitly with /load (or /models) from the chat, so the
2326 // UI comes up immediately without a multi-minute download/load.
2327 + // Project instructions are injected at load time in `chat.rs`.
2328 let _ = load_tx.send(Ok(()));
2329 let backend =
2330 Arc::new(LocalBackend::new(Arc::clone(&engine))) as Arc<dyn InferenceBackend>;
src/skills.rs new
+613
@@ -0,0 +1,613 @@
1 +//! Agent Skills support for siGit Code.
2 +//!
3 +//! Implements the open [Agent Skills](https://agentskills.io) format: a skill is
4 +//! a directory containing a `SKILL.md` file with YAML frontmatter (`name` +
5 +//! `description`, plus optional fields) followed by Markdown instructions. Skills
6 +//! may bundle `scripts/`, `references/`, and `assets/` the agent loads on demand.
7 +//!
8 +//! Loading follows the spec's *progressive disclosure*:
9 +//!
10 +//! 1. **Discovery** — at turn-build time we scan the skill roots and load only
11 +//! each skill's `name` and `description` into the `skill` tool's description,
12 +//! so the model knows what's available for a small context cost.
13 +//! 2. **Activation** — when a task matches, the model calls the `skill` tool with
14 +//! a name; [`activate_skill`] reads the full `SKILL.md` body into context.
15 +//! 3. **Execution** — the model follows the instructions, reading bundled files
16 +//! (under the reported skill directory) with the normal file/command tools.
17 +//!
18 +//! Skills are discovered from, in priority order (earlier wins on name clashes):
19 +//!
20 +//! - `<cwd>/.sigit/skills/` and `<cwd>/.claude/skills/` (project-local)
21 +//! - `$SIGIT_CONFIG_DIR/skills/` (default `~/.config/sigit/skills/`)
22 +//! - `~/.claude/skills/` (shared with the broader ecosystem)
23 +
24 +use std::path::{Path, PathBuf};
25 +
26 +use serde_json::{Value, json};
27 +
28 +/// The agent-facing tool name used to activate a skill.
29 +pub const SKILL_TOOL_NAME: &str = "skill";
30 +
31 +/// Hard cap on how many skills we advertise, to bound the tool description size.
32 +const MAX_ADVERTISED_SKILLS: usize = 100;
33 +
34 +/// A discovered skill: its identifying metadata plus where it lives on disk.
35 +#[derive(Debug, Clone, PartialEq, Eq)]
36 +pub struct Skill {
37 + /// The `name` from frontmatter. Lowercase alphanumeric + single hyphens.
38 + pub name: String,
39 + /// The `description` from frontmatter: what the skill does and when to use it.
40 + pub description: String,
41 + /// Optional `license` field.
42 + pub license: Option<String>,
43 + /// Optional `compatibility` field (environment requirements).
44 + pub compatibility: Option<String>,
45 + /// The skill's root directory (the one holding `SKILL.md`).
46 + pub dir: PathBuf,
47 +}
48 +
49 +impl Skill {
50 + /// Absolute path to this skill's `SKILL.md`.
51 + fn skill_md(&self) -> PathBuf {
52 + self.dir.join("SKILL.md")
53 + }
54 +}
55 +
56 +/// JSON Schema for the `skill` tool's arguments.
57 +pub fn skill_tool_schema() -> Value {
58 + json!({
59 + "type": "object",
60 + "properties": {
61 + "name": {
62 + "type": "string",
63 + "description": "The name of the skill to activate, exactly as listed in this tool's description."
64 + }
65 + },
66 + "required": ["name"],
67 + "additionalProperties": false
68 + })
69 +}
70 +
71 +/// Build the `skill` tool description, embedding the discovery list (each skill's
72 +/// `name` and `description`) so the model can decide when to activate one.
73 +pub fn skill_tool_description(skills: &[Skill]) -> String {
74 + let mut out = String::from(
75 + "Activate an Agent Skill to load its full instructions into context. \
76 + Skills are reusable, on-demand capabilities — specialized knowledge and \
77 + step-by-step workflows packaged as a folder. Only the name and description \
78 + of each skill are loaded up front; calling this tool with a skill's `name` \
79 + reads its full instructions (and tells you the skill's directory, so you \
80 + can read any bundled scripts, references, or assets with the file and \
81 + command tools). Activate a skill as soon as the user's task matches one of \
82 + the descriptions below; follow its instructions over your defaults.\n\n\
83 + Available skills:\n",
84 + );
85 + for skill in skills.iter().take(MAX_ADVERTISED_SKILLS) {
86 + out.push_str("- ");
87 + out.push_str(&skill.name);
88 + out.push_str(": ");
89 + out.push_str(&skill.description);
90 + out.push('\n');
91 + }
92 + out
93 +}
94 +
95 +/// Human-readable list of discovered skills, for the `/skills` slash command.
96 +pub fn format_skills_list() -> String {
97 + let skills = discover_skills();
98 + if skills.is_empty() {
99 + return "No skills found. Add a skill folder (with a SKILL.md) under \
100 + .sigit/skills/ or .claude/skills/ in your project, or under \
101 + ~/.config/sigit/skills/. See https://agentskills.io."
102 + .to_string();
103 + }
104 +
105 + let mut lines = vec![format!("{} skill(s) available:", skills.len())];
106 + for skill in &skills {
107 + lines.push(format!("- {}: {}", skill.name, skill.description));
108 + }
109 + lines.push(String::new());
110 + lines.push(
111 + "I activate a skill automatically when your task matches its description.".to_string(),
112 + );
113 + lines.join("\n")
114 +}
115 +
116 +/// Execute the `skill` tool: parse the requested name and return the full
117 +/// `SKILL.md` body, prefixed with the skill's directory so relative references
118 +/// (e.g. `scripts/foo.py`, `references/REFERENCE.md`) can be resolved.
119 +pub fn activate_skill(arguments: &str) -> String {
120 + let args: Value = match serde_json::from_str(arguments) {
121 + Ok(v) => v,
122 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
123 + };
124 +
125 + let name = match args.get("name").and_then(Value::as_str) {
126 + Some(n) => n.trim(),
127 + None => return "Error: missing required parameter \"name\"".to_string(),
128 + };
129 +
130 + // Re-discover so activation always reflects the skills on disk right now.
131 + let skills = discover_skills();
132 + let Some(skill) = skills.iter().find(|s| s.name == name) else {
133 + if skills.is_empty() {
134 + return format!("Error: no skill named \"{name}\" is available (no skills found).");
135 + }
136 + let available = skills
137 + .iter()
138 + .map(|s| s.name.as_str())
139 + .collect::<Vec<_>>()
140 + .join(", ");
141 + return format!("Error: no skill named \"{name}\". Available skills: {available}.");
142 + };
143 +
144 + let body = match read_skill_body(&skill.skill_md()) {
145 + Ok(body) => body,
146 + Err(err) => {
147 + return format!(
148 + "Error: could not read SKILL.md for \"{name}\" at {}: {err}",
149 + skill.skill_md().display()
150 + );
151 + }
152 + };
153 +
154 + // Surface the optional metadata so the agent (and user) can sanity-check
155 + // environment requirements before following the instructions.
156 + let mut notes = String::new();
157 + if let Some(compatibility) = &skill.compatibility {
158 + notes.push_str(&format!("Compatibility: {compatibility}\n"));
159 + }
160 + if let Some(license) = &skill.license {
161 + notes.push_str(&format!("License: {license}\n"));
162 + }
163 + if !notes.is_empty() {
164 + notes.push('\n');
165 + }
166 +
167 + let dir = skill.dir.display();
168 + format!(
169 + "Skill \"{name}\" activated. Its directory is {dir} — resolve any relative \
170 + file references (scripts/, references/, assets/) against that path. Follow \
171 + these instructions:\n\n{notes}{body}"
172 + )
173 +}
174 +
175 +/// Discover all valid skills across the known roots. Earlier roots win when two
176 +/// skills share a `name`. The result is sorted by name for stable output.
177 +pub fn discover_skills() -> Vec<Skill> {
178 + let mut skills: Vec<Skill> = Vec::new();
179 + let mut seen_names: Vec<String> = Vec::new();
180 +
181 + for root in skill_roots() {
182 + collect_skills_from_root(&root, &mut skills, &mut seen_names);
183 + }
184 +
185 + skills.sort_by(|a, b| a.name.cmp(&b.name));
186 + skills
187 +}
188 +
189 +/// The skill directories to scan, in priority order.
190 +fn skill_roots() -> Vec<PathBuf> {
191 + let mut roots = Vec::new();
192 +
193 + // Project-local skills win over user-global ones.
194 + if let Ok(cwd) = std::env::current_dir() {
195 + roots.push(cwd.join(".sigit").join("skills"));
196 + roots.push(cwd.join(".claude").join("skills"));
197 + }
198 +
199 + // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
200 + if let Some(config_dir) = sigit_config_dir() {
201 + roots.push(config_dir.join("skills"));
202 + }
203 +
204 + // Shared with the broader Agent Skills ecosystem.
205 + if let Some(home) = home_dir() {
206 + roots.push(home.join(".claude").join("skills"));
207 + }
208 +
209 + roots
210 +}
211 +
212 +fn sigit_config_dir() -> Option<PathBuf> {
213 + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
214 + && !dir.is_empty()
215 + {
216 + return Some(PathBuf::from(dir));
217 + }
218 + home_dir().map(|home| home.join(".config").join("sigit"))
219 +}
220 +
221 +fn home_dir() -> Option<PathBuf> {
222 + std::env::var("HOME").ok().map(PathBuf::from)
223 +}
224 +
225 +/// Scan a single root for skill subdirectories, appending newly-seen skills.
226 +fn collect_skills_from_root(root: &Path, skills: &mut Vec<Skill>, seen_names: &mut Vec<String>) {
227 + let entries = match std::fs::read_dir(root) {
228 + Ok(entries) => entries,
229 + // Most roots won't exist; that's expected, not an error.
230 + Err(_) => return,
231 + };
232 +
233 + for entry in entries.flatten() {
234 + let dir = entry.path();
235 + if !dir.is_dir() {
236 + continue;
237 + }
238 +
239 + let skill_md = dir.join("SKILL.md");
240 + if !skill_md.is_file() {
241 + continue;
242 + }
243 +
244 + let contents = match std::fs::read_to_string(&skill_md) {
245 + Ok(contents) => contents,
246 + Err(error) => {
247 + log::warn!("skipping skill at {}: {error}", skill_md.display());
248 + continue;
249 + }
250 + };
251 +
252 + let skill = match parse_skill(&contents, &dir) {
253 + Ok(skill) => skill,
254 + Err(error) => {
255 + log::warn!("skipping invalid skill at {}: {error}", skill_md.display());
256 + continue;
257 + }
258 + };
259 +
260 + // First root to define a name wins; later duplicates are ignored.
261 + if seen_names.iter().any(|name| name == &skill.name) {
262 + log::debug!(
263 + "skill \"{}\" at {} shadowed by an earlier definition",
264 + skill.name,
265 + dir.display()
266 + );
267 + continue;
268 + }
269 +
270 + seen_names.push(skill.name.clone());
271 + skills.push(skill);
272 + }
273 +}
274 +
275 +/// Parse a `SKILL.md` into a [`Skill`], validating the required fields against
276 +/// the Agent Skills spec. `dir` is the skill's root directory.
277 +fn parse_skill(contents: &str, dir: &Path) -> Result<Skill, String> {
278 + let frontmatter = extract_frontmatter(contents)
279 + .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
280 +
281 + let fields = parse_frontmatter_fields(frontmatter);
282 +
283 + let name = fields
284 + .iter()
285 + .find(|(k, _)| k == "name")
286 + .map(|(_, v)| v.clone())
287 + .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
288 + validate_name(&name)?;
289 +
290 + let description = fields
291 + .iter()
292 + .find(|(k, _)| k == "description")
293 + .map(|(_, v)| v.clone())
294 + .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
295 + if description.is_empty() {
296 + return Err("`description` must not be empty".to_string());
297 + }
298 + if description.chars().count() > 1024 {
299 + return Err("`description` exceeds the 1024-character limit".to_string());
300 + }
301 +
302 + // The spec requires `name` to match the parent directory name. Warn but stay
303 + // lenient — the frontmatter name is the identity used for activation.
304 + if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str())
305 + && dir_name != name
306 + {
307 + log::warn!(
308 + "skill name \"{name}\" does not match its directory \"{dir_name}\" at {}",
309 + dir.display()
310 + );
311 + }
312 +
313 + let license = fields
314 + .iter()
315 + .find(|(k, _)| k == "license")
316 + .map(|(_, v)| v.clone())
317 + .filter(|v| !v.is_empty());
318 + let compatibility = fields
319 + .iter()
320 + .find(|(k, _)| k == "compatibility")
321 + .map(|(_, v)| v.clone())
322 + .filter(|v| !v.is_empty());
323 +
324 + Ok(Skill {
325 + name,
326 + description,
327 + license,
328 + compatibility,
329 + dir: dir.to_path_buf(),
330 + })
331 +}
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> {
336 + let len = name.chars().count();
337 + if len == 0 {
338 + return Err("`name` must not be empty".to_string());
339 + }
340 + if len > 64 {
341 + return Err("`name` exceeds the 64-character limit".to_string());
342 + }
343 + if !name
344 + .chars()
345 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
346 + {
347 + return Err("`name` may only contain lowercase letters, digits, and hyphens".to_string());
348 + }
349 + if name.starts_with('-') || name.ends_with('-') {
350 + return Err("`name` must not start or end with a hyphen".to_string());
351 + }
352 + if name.contains("--") {
353 + return Err("`name` must not contain consecutive hyphens".to_string());
354 + }
355 + Ok(())
356 +}
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> {
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;
364 + loop {
365 + let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
366 + let (line, after) = rest.split_at(line_end);
367 + if line.trim().is_empty() {
368 + rest = after;
369 + continue;
370 + }
371 + if line.trim() != "---" {
372 + return None;
373 + }
374 + // `after` now begins just past the opening `---` line.
375 + let body = after;
376 + let mut search = body;
377 + let mut offset = 0;
378 + loop {
379 + let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
380 + let (l, a) = search.split_at(end);
381 + if l.trim() == "---" {
382 + return Some(&body[..offset]);
383 + }
384 + if a.is_empty() {
385 + return None;
386 + }
387 + offset += end;
388 + search = a;
389 + }
390 + }
391 +}
392 +
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)> {
397 + let mut fields = Vec::new();
398 + for line in frontmatter.lines() {
399 + // Indented lines belong to a nested mapping/sequence — skip them.
400 + if line.starts_with(char::is_whitespace) {
401 + continue;
402 + }
403 + let line = line.trim_end();
404 + if line.is_empty() || line.starts_with('#') {
405 + continue;
406 + }
407 + let Some((key, value)) = line.split_once(':') else {
408 + continue;
409 + };
410 + let key = key.trim();
411 + if key.is_empty() {
412 + continue;
413 + }
414 + let value = unquote(value.trim());
415 + fields.push((key.to_string(), value));
416 + }
417 + fields
418 +}
419 +
420 +/// Strip a single layer of matching single or double quotes; otherwise return
421 +/// the value unchanged. Also drops a trailing `# comment` on unquoted scalars.
422 +fn unquote(value: &str) -> String {
423 + if value.len() >= 2 {
424 + let bytes = value.as_bytes();
425 + let first = bytes[0];
426 + let last = bytes[value.len() - 1];
427 + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
428 + return value[1..value.len() - 1].to_string();
429 + }
430 + }
431 + value.to_string()
432 +}
433 +
434 +/// Read the Markdown body of a `SKILL.md` (everything after the frontmatter),
435 +/// falling back to the whole file if no frontmatter delimiter is found.
436 +fn read_skill_body(skill_md: &Path) -> Result<String, String> {
437 + let contents = std::fs::read_to_string(skill_md).map_err(|e| e.to_string())?;
438 + Ok(strip_frontmatter(&contents).trim().to_string())
439 +}
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 {
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') {
447 + Some(rest) => rest,
448 + None => return contents,
449 + },
450 + None => return contents,
451 + };
452 + // Find the closing `---` line.
453 + let mut search = after_opener;
454 + let mut offset = 0;
455 + loop {
456 + let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
457 + let (line, after) = search.split_at(end);
458 + if line.trim() == "---" {
459 + return &after_opener[offset + end..];
460 + }
461 + if after.is_empty() {
462 + return contents;
463 + }
464 + offset += end;
465 + search = after;
466 + }
467 +}
468 +
469 +#[cfg(test)]
470 +mod tests {
471 + use super::*;
472 + use std::fs;
473 +
474 + fn unique_dir(name: &str) -> PathBuf {
475 + let nanos = std::time::SystemTime::now()
476 + .duration_since(std::time::UNIX_EPOCH)
477 + .unwrap()
478 + .as_nanos();
479 + std::env::temp_dir().join(format!("sigit-skills-test-{name}-{nanos}"))
480 + }
481 +
482 + #[test]
483 + fn validate_name_accepts_valid_names() {
484 + assert!(validate_name("pdf-processing").is_ok());
485 + assert!(validate_name("data-analysis").is_ok());
486 + assert!(validate_name("code-review").is_ok());
487 + assert!(validate_name("a").is_ok());
488 + assert!(validate_name("skill1").is_ok());
489 + }
490 +
491 + #[test]
492 + fn validate_name_rejects_invalid_names() {
493 + assert!(validate_name("").is_err());
494 + assert!(validate_name("PDF-Processing").is_err());
495 + assert!(validate_name("-pdf").is_err());
496 + assert!(validate_name("pdf-").is_err());
497 + assert!(validate_name("pdf--processing").is_err());
498 + assert!(validate_name("pdf_processing").is_err());
499 + assert!(validate_name(&"a".repeat(65)).is_err());
500 + }
501 +
502 + #[test]
503 + fn extract_frontmatter_reads_block() {
504 + let md = "---\nname: foo\ndescription: bar\n---\n\nBody here.\n";
505 + let fm = extract_frontmatter(md).expect("frontmatter");
506 + assert!(fm.contains("name: foo"));
507 + assert!(fm.contains("description: bar"));
508 + assert!(!fm.contains("Body here"));
509 + }
510 +
511 + #[test]
512 + fn extract_frontmatter_none_without_delimiter() {
513 + assert!(extract_frontmatter("no frontmatter here").is_none());
514 + assert!(extract_frontmatter("---\nname: foo\n").is_none());
515 + }
516 +
517 + #[test]
518 + fn parse_fields_handles_quotes_and_nesting() {
519 + let fm = "name: pdf-processing\ndescription: \"Extract PDF text\"\nmetadata:\n author: me\n version: \"1.0\"\nlicense: Apache-2.0\n";
520 + let fields = parse_frontmatter_fields(fm);
521 + let get = |k: &str| {
522 + fields
523 + .iter()
524 + .find(|(key, _)| key == k)
525 + .map(|(_, v)| v.clone())
526 + };
527 + assert_eq!(get("name").as_deref(), Some("pdf-processing"));
528 + assert_eq!(get("description").as_deref(), Some("Extract PDF text"));
529 + assert_eq!(get("license").as_deref(), Some("Apache-2.0"));
530 + // Nested keys under `metadata:` are skipped.
531 + assert!(get("author").is_none());
532 + assert!(get("version").is_none());
533 + }
534 +
535 + #[test]
536 + fn parse_skill_requires_name_and_description() {
537 + let dir = Path::new("/tmp/example-skill");
538 + assert!(parse_skill("---\ndescription: x\n---\n", dir).is_err());
539 + assert!(parse_skill("---\nname: x\n---\n", dir).is_err());
540 + let ok = parse_skill(
541 + "---\nname: example-skill\ndescription: does things\n---\nbody",
542 + dir,
543 + );
544 + assert!(ok.is_ok());
545 + let skill = ok.unwrap();
546 + assert_eq!(skill.name, "example-skill");
547 + assert_eq!(skill.description, "does things");
548 + }
549 +
550 + #[test]
551 + fn strip_frontmatter_returns_body() {
552 + let md = "---\nname: foo\ndescription: bar\n---\n\n# Heading\n\nText.\n";
553 + assert_eq!(strip_frontmatter(md).trim(), "# Heading\n\nText.");
554 + }
555 +
556 + #[test]
557 + fn strip_frontmatter_passes_through_without_block() {
558 + assert_eq!(strip_frontmatter("just body"), "just body");
559 + }
560 +
561 + #[test]
562 + fn discover_and_activate_roundtrip() {
563 + let root = unique_dir("roundtrip");
564 + let skills_root = root.join(".sigit").join("skills");
565 + let skill_dir = skills_root.join("hello-world");
566 + fs::create_dir_all(&skill_dir).unwrap();
567 + fs::write(
568 + skill_dir.join("SKILL.md"),
569 + "---\nname: hello-world\ndescription: Say hello. Use when greeting.\n---\n\nGreet the user warmly.\n",
570 + )
571 + .unwrap();
572 +
573 + // discover_skills() reads the current directory, so run from `root`.
574 + let prev = std::env::current_dir().unwrap();
575 + std::env::set_current_dir(&root).unwrap();
576 +
577 + let skills = discover_skills();
578 + let found = skills.iter().find(|s| s.name == "hello-world");
579 + assert!(found.is_some(), "expected to discover hello-world");
580 + assert_eq!(found.unwrap().description, "Say hello. Use when greeting.");
581 +
582 + let activated = activate_skill(r#"{"name": "hello-world"}"#);
583 + assert!(activated.contains("Greet the user warmly."));
584 + assert!(activated.contains("activated"));
585 +
586 + let missing = activate_skill(r#"{"name": "nope"}"#);
587 + assert!(missing.contains("no skill named"));
588 +
589 + std::env::set_current_dir(prev).unwrap();
590 + let _ = fs::remove_dir_all(&root);
591 + }
592 +
593 + #[test]
594 + fn skill_tool_description_lists_skills() {
595 + let skills = vec![Skill {
596 + name: "pdf-processing".to_string(),
597 + description: "Extract PDF text".to_string(),
598 + license: None,
599 + compatibility: None,
600 + dir: PathBuf::from("/x/pdf-processing"),
601 + }];
602 + let desc = skill_tool_description(&skills);
603 + assert!(desc.contains("Available skills:"));
604 + assert!(desc.contains("- pdf-processing: Extract PDF text"));
605 + }
606 +
607 + #[test]
608 + fn skill_tool_schema_requires_name() {
609 + let schema = skill_tool_schema();
610 + assert_eq!(schema["type"], "object");
611 + assert_eq!(schema["required"][0], "name");
612 + }
613 +}
src/tools.rs
+1
@@ -253,6 +253,7 @@ pub async fn execute_tool(name: &str, arguments: &str) -> String {
253 "edit_file" => exec_edit_file(arguments),
254 "delete_file" => exec_delete_file(arguments),
255 "run_command" => exec_run_command(arguments),
256 + "skill" => crate::skills::activate_skill(arguments),
257 _ => format!("Unknown tool: {name}"),
258 }
259 }