@setoelkahfi / sigit / commits / a0d09a6

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 }