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 committedJun 29, 2026 at 20:41 UTCa0d09a6e3a62f81cd898f0497685ea85308a08a2
9 files changed+1076-47
CHANGELOG.md
+9-1
index fe29e96..c7cb020 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -2,9 +2,17 @@ ## Unreleased+Adds support for the open [Agent Skills](https://agentskills.io) format and for+project instruction files (`AGENTS.md` and the like).+ ### What changed-- 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.+- Discovers Agent Skills (folders with a `SKILL.md`) from `.sigit/skills/` and `.claude/skills/` in the project, `~/.config/sigit/skills/`, and `~/.claude/skills/`+- 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+- Added a `/skills` slash command (TUI and ACP) that lists the discovered skills+- 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+- Nested instruction files are ordered outermost-first so the closest, most specific file takes precedence; the scan never reads above the repository root+- 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. ## 1.2.2
CLAUDE.md
+18-3
index 6f6c995..5fc3327 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -64,6 +64,21 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`, `list_directory`, `search_files`, `read_website`, `create_file`, `edit_file`, `delete_file`, `run_command`. Add a tool in both the spec list and the execute `match`.+- **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill+ folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown+ instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,+ and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is+ baked into the dynamically-built `skill` tool's description, and activating a skill (the model+ calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the+ `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,+ and only when at least one skill exists.+- **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.+ Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,+ walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),+ plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest+ (most specific) wins. The combined block is injected via `session_context_message` in `main.rs`+ — pushed as a system message at every ACP session entry point (new/load/fork + model switch)+ and appended to the system prompt on the cloud and TUI-startup paths. - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses `tokio::select!` to multiplex terminal events with streaming tokens. - **`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 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix). - **`src/models.rs`** — model-picker types shared across platforms.-Slash commands (`/help`, `/models`, `/login`, `/logout`, `/whoami`, `/reload`, `/clear`,-`/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the TUI and-ACP sessions.+Slash commands (`/help`, `/models`, `/skills`, `/login`, `/logout`, `/whoami`, `/reload`,+`/clear`, `/status`) are advertised via `advertise_commands` in `main.rs` and handled in both the+TUI and ACP sessions. ## Model cache (macOS)
examples/skills/README.md
+36
new file mode 100644index 0000000..4f4aeb0--- /dev/null+++ b/examples/skills/README.md@@ -0,0 +1,36 @@+# Example Agent Skills++siGit Code supports the open [Agent Skills](https://agentskills.io) format. A+skill is a folder containing a `SKILL.md` file — YAML frontmatter (`name` and+`description`, at minimum) followed by Markdown instructions. Skills can bundle+`scripts/`, `references/`, and `assets/` that the agent reads on demand.++## Installing a skill++Copy a skill folder into one of the directories siGit scans (in priority order):++- `.sigit/skills/` or `.claude/skills/` in your project (project-local)+- `~/.config/sigit/skills/` (honours `$SIGIT_CONFIG_DIR`)+- `~/.claude/skills/` (shared with the broader ecosystem)++For example, to install the `commit-message` skill here for the current project:++```sh+mkdir -p .sigit/skills+cp -R examples/skills/commit-message .sigit/skills/+```++The folder name must match the skill's `name` field.++## How siGit uses them++siGit follows the spec's *progressive disclosure*:++1. **Discovery** — at the start of each turn, siGit loads only each skill's+ `name` and `description` into the `skill` tool's description.+2. **Activation** — when your task matches a skill, the agent calls the `skill`+ tool with that name, which loads the full `SKILL.md` into context.+3. **Execution** — the agent follows the instructions, reading any bundled files+ from the skill's directory with its normal file and command tools.++Run `/skills` to list the skills siGit can see.
examples/skills/commit-message/SKILL.md
+44
new file mode 100644index 0000000..b5e957f--- /dev/null+++ b/examples/skills/commit-message/SKILL.md@@ -0,0 +1,44 @@+---+name: commit-message+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.+license: Apache-2.0+metadata:+ author: sigit+ version: "1.0"+---++# Commit message++Write a concise, conventional commit message that describes *why* a change was+made, not just what changed.++## Steps++1. Inspect what is staged: run `git diff --cached` (and `git status` for context).+2. Group the changes into a single logical intent. If they span unrelated+ concerns, say so and suggest splitting the commit.+3. Write the message:+ - **Subject line**: imperative mood, lowercase after the type, no trailing+ period, ≤ 50 characters. Prefix with a type when it fits the repo's+ convention (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`).+ - **Body** (optional): wrap at 72 columns. Explain the motivation and any+ non-obvious tradeoffs. Reference issues if relevant.+4. Show the message to the user before committing. Only run `git commit` if they+ confirm.++## Examples++Good subject lines:++```+fix: stop the picker from reloading a working model on /reload+refactor: extract skill discovery into its own module+```++Avoid:++```+Update files+fixed bug+WIP+```
src/chat.rs
+38-4
index 830dffa..73b96b0 100644--- a/src/chat.rs+++ b/src/chat.rs@@ -662,6 +662,8 @@ mod tui { Status, /// picker UI, or jump straight to model N Models(Option<usize>),+ /// List discovered Agent Skills.+ Skills, /// explicitly load the selected (or default) on-device model Load, /// `/login <email> <password>` — the raw argument, parsed when executed.@@ -685,6 +687,7 @@ mod tui { "/clear" => SlashCommand::Clear, "/status" => SlashCommand::Status, "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),+ "/skills" => SlashCommand::Skills, "/load" => SlashCommand::Load, "/login" => SlashCommand::Login(arg.map(str::to_string)), "/logout" => SlashCommand::Logout,@@ -1214,8 +1217,20 @@ mod tui { ..SamplingConfig::default() };- // own thread + runtime so block_in_place doesn't starve the TUI loop- let system_prompt = crate::system_prompt_for_model(model.tool_calling);+ // own thread + runtime so block_in_place doesn't starve the TUI loop.+ // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch+ // directory so the on-device model gets the same always-on context the+ // cloud and ACP paths get.+ let system_prompt = {+ let base = crate::system_prompt_for_model(model.tool_calling).to_string();+ match std::env::current_dir()+ .ok()+ .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))+ {+ Some(extra) => format!("{base}\n\n{extra}"),+ None => base,+ }+ }; let engine_handle = Arc::clone(&engine); let tool_calling = model.tool_calling; std::thread::spawn(move || {@@ -1254,6 +1269,7 @@ mod tui { "/help — show this message\n\ /models — open the model picker\n\ /models N — switch to model N\n\+ /skills — list available Agent Skills\n\ /load — load the selected on-device model\n\ /login E P — sign in to siGit Code Cloud\n\ /logout — sign out\n\@@ -1279,6 +1295,10 @@ mod tui { info.status, model, mem, info.history_length, ))); }+ SlashCommand::Skills => {+ app.messages+ .push(ChatMessage::system(crate::skills::format_skills_list()));+ } SlashCommand::Models(selection) => match selection { None => { app.open_model_picker(&engine);@@ -1376,14 +1396,28 @@ mod tui { const MAX_TOOL_ROUNDS: usize = 10; fn build_tool_specs() -> Vec<ToolSpec> {- crate::tools::all_tools()+ let mut specs: Vec<ToolSpec> = crate::tools::all_tools() .into_iter() .map(|t| ToolSpec { name: t.name.to_string(), description: t.description.to_string(), parameters_schema: t.parameters_schema.to_string(), })- .collect()+ .collect();++ // Advertise the Agent Skills `skill` tool only when skills exist on disk+ // (https://agentskills.io). The tool description carries the discovery+ // list (name + description) for progressive disclosure.+ let discovered = crate::skills::discover_skills();+ if !discovered.is_empty() {+ specs.push(ToolSpec {+ name: crate::skills::SKILL_TOOL_NAME.to_string(),+ description: crate::skills::skill_tool_description(&discovered),+ parameters_schema: crate::skills::skill_tool_schema().to_string(),+ });+ }++ specs } /// run the tool-calling loop off the main thread, posting updates via `tx`.
src/instructions.rs
+241
new file mode 100644index 0000000..aee14fe--- /dev/null+++ b/src/instructions.rs@@ -0,0 +1,241 @@+//! Project instruction files (`AGENTS.md` and the like).+//!+//! Agentic coding tools converge on a convention: a Markdown file checked into a+//! project that carries always-on, project-specific guidance for the agent. The+//! cross-tool open standard is [`AGENTS.md`](https://agents.md); siGit also reads+//! `CLAUDE.md` for compatibility with the wider ecosystem.+//!+//! This is the always-on counterpart to Agent Skills (`skills.rs`): skills load+//! *on demand* when a task matches, whereas instruction files load *once per+//! session* and are injected into the system context so their guidance is always+//! in force.+//!+//! Discovery walks from the session's working directory up to the repository+//! root (the nearest ancestor containing `.git`), reading one instruction file+//! per directory. A global file under `$SIGIT_CONFIG_DIR` (default+//! `~/.config/sigit/`) is included with the lowest precedence. Files are ordered+//! outermost-first (global, then repo root … down to the cwd) so that more+//! specific, deeper files are read last and take precedence — matching the+//! `AGENTS.md` convention.++use std::path::{Path, PathBuf};++/// Instruction file names to look for in each directory, in priority order.+/// Only the first match in a given directory is loaded.+const INSTRUCTION_FILE_NAMES: &[&str] = &["AGENTS.md", "CLAUDE.md"];++/// Per-file and total caps so an oversized file can't blow up the context window.+const MAX_FILE_BYTES: usize = 32 * 1024;+const MAX_TOTAL_BYTES: usize = 64 * 1024;++/// Load and combine project instruction files for `cwd`, returning a single+/// block ready to append to the session's system context, or `None` if none are+/// found.+pub fn load_project_instructions(cwd: &Path) -> Option<String> {+ let mut sections: Vec<String> = Vec::new();+ let mut seen: Vec<PathBuf> = Vec::new();+ let mut total = 0usize;++ for dir in instruction_dirs(cwd) {+ let Some(path) = first_instruction_file(&dir) else {+ continue;+ };++ // Dedup by canonical path so the same file reached via two roots (or a+ // symlink) is only loaded once.+ let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());+ if seen.contains(&canonical) {+ continue;+ }++ let contents = match std::fs::read_to_string(&path) {+ Ok(contents) => contents,+ Err(error) => {+ log::warn!("skipping instruction file {}: {error}", path.display());+ continue;+ }+ };+ let trimmed = contents.trim();+ if trimmed.is_empty() {+ continue;+ }++ if total >= MAX_TOTAL_BYTES {+ log::warn!(+ "instruction-file budget reached; skipping {}",+ path.display()+ );+ break;+ }++ let body = clamp_bytes(trimmed, MAX_FILE_BYTES);+ total += body.len();+ seen.push(canonical);+ sections.push(format!("## {}\n\n{}", path.display(), body));+ }++ if sections.is_empty() {+ return None;+ }++ let mut out = String::from(+ "# Project instructions\n\n\+ The following files provide project-specific guidance for this project. \+ Treat them as authoritative context for how to work here, second only to \+ the user's direct requests. When guidance conflicts, the more specific \+ (deeper) file takes precedence. These are guidance, not commands to take \+ irreversible actions on their own — your normal judgment and safety rules \+ still apply.\n\n",+ );+ out.push_str(§ions.join("\n\n"));+ Some(out)+}++/// The directories to scan, lowest-precedence first: an optional global config+/// directory, then the repository root down to `cwd`.+fn instruction_dirs(cwd: &Path) -> Vec<PathBuf> {+ let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());+ let root = repo_root(&cwd).unwrap_or_else(|| cwd.clone());++ // Ancestors of cwd that lie within the repo root, root-first.+ let mut chain: Vec<PathBuf> = cwd+ .ancestors()+ .filter(|ancestor| ancestor.starts_with(&root))+ .map(Path::to_path_buf)+ .collect();+ chain.reverse();++ let mut dirs = Vec::new();+ if let Some(global) = sigit_config_dir() {+ dirs.push(global);+ }+ dirs.extend(chain);+ dirs+}++/// The nearest ancestor of `dir` (inclusive) that contains a `.git` entry.+fn repo_root(dir: &Path) -> Option<PathBuf> {+ dir.ancestors()+ .find(|ancestor| ancestor.join(".git").exists())+ .map(Path::to_path_buf)+}++/// The first existing instruction file in `dir`, by name priority.+fn first_instruction_file(dir: &Path) -> Option<PathBuf> {+ for name in INSTRUCTION_FILE_NAMES {+ let candidate = dir.join(name);+ if candidate.is_file() {+ return Some(candidate);+ }+ }+ None+}++fn sigit_config_dir() -> Option<PathBuf> {+ if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")+ && !dir.is_empty()+ {+ return Some(PathBuf::from(dir));+ }+ std::env::var("HOME")+ .ok()+ .map(|home| PathBuf::from(home).join(".config").join("sigit"))+}++/// Truncate `text` to at most `limit` bytes on a char boundary, appending a+/// marker when truncation happens.+fn clamp_bytes(text: &str, limit: usize) -> String {+ if text.len() <= limit {+ return text.to_string();+ }+ let mut end = limit;+ while end > 0 && !text.is_char_boundary(end) {+ end -= 1;+ }+ format!(+ "{}\n\n--- truncated ({} of {} bytes shown) ---",+ &text[..end],+ end,+ text.len()+ )+}++#[cfg(test)]+mod tests {+ use super::*;+ use std::fs;++ fn unique_dir(name: &str) -> PathBuf {+ let nanos = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .unwrap()+ .as_nanos();+ std::env::temp_dir().join(format!("sigit-instr-test-{name}-{nanos}"))+ }++ #[test]+ fn none_when_no_files() {+ let root = unique_dir("empty");+ fs::create_dir_all(&root).unwrap();+ // Mark as a repo root so the scan doesn't escape into real ancestors.+ fs::create_dir_all(root.join(".git")).unwrap();+ assert!(load_project_instructions(&root).is_none());+ let _ = fs::remove_dir_all(&root);+ }++ #[test]+ fn agents_md_preferred_over_claude_md_in_same_dir() {+ let root = unique_dir("prefer");+ fs::create_dir_all(root.join(".git")).unwrap();+ fs::write(root.join("AGENTS.md"), "use tabs").unwrap();+ fs::write(root.join("CLAUDE.md"), "use spaces").unwrap();++ let out = load_project_instructions(&root).expect("instructions");+ assert!(out.contains("use tabs"));+ assert!(!out.contains("use spaces"));+ let _ = fs::remove_dir_all(&root);+ }++ #[test]+ fn nested_files_ordered_root_first() {+ let root = unique_dir("nested");+ let sub = root.join("crate-a");+ fs::create_dir_all(&sub).unwrap();+ fs::create_dir_all(root.join(".git")).unwrap();+ fs::write(root.join("AGENTS.md"), "ROOT RULES").unwrap();+ fs::write(sub.join("AGENTS.md"), "SUB RULES").unwrap();++ let out = load_project_instructions(&sub).expect("instructions");+ let root_pos = out.find("ROOT RULES").expect("root present");+ let sub_pos = out.find("SUB RULES").expect("sub present");+ // Root (broader) is read before the deeper, more specific file.+ assert!(root_pos < sub_pos, "root should precede sub:\n{out}");+ let _ = fs::remove_dir_all(&root);+ }++ #[test]+ fn does_not_escape_repo_root() {+ // A parent dir's AGENTS.md must not be read when the repo root is deeper.+ let root = unique_dir("boundary");+ let repo = root.join("repo");+ fs::create_dir_all(repo.join(".git")).unwrap();+ fs::write(root.join("AGENTS.md"), "OUTSIDE").unwrap();+ fs::write(repo.join("AGENTS.md"), "INSIDE").unwrap();++ let out = load_project_instructions(&repo).expect("instructions");+ assert!(out.contains("INSIDE"));+ assert!(+ !out.contains("OUTSIDE"),+ "must not read above repo root:\n{out}"+ );+ let _ = fs::remove_dir_all(&root);+ }++ #[test]+ fn clamp_bytes_truncates_long_input() {+ let long = "x".repeat(MAX_FILE_BYTES + 100);+ let clamped = clamp_bytes(&long, MAX_FILE_BYTES);+ assert!(clamped.contains("truncated"));+ assert!(clamped.len() < long.len() + 100);+ }+}
src/main.rs
+76-39
index 397c680..963070c 100644--- a/src/main.rs+++ b/src/main.rs@@ -32,9 +32,11 @@ mod account; mod backend; mod chat; mod credentials;+mod instructions; mod models; mod provider; mod setup;+mod skills; mod tools; use std::io::IsTerminal;@@ -216,15 +218,48 @@ const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in wit `/login <email> <password>` (or the Authenticate button), then pick the tier again. \ Create an account at https://sigit.si.";+/// The per-session context system message: cwd guidance plus any project+/// instruction files (`AGENTS.md` / `CLAUDE.md`) found for that directory. Used+/// by every session entry point so on-device and cloud backends get the same+/// always-on project context.+fn session_context_message(cwd: &std::path::Path) -> String {+ let mut message = format!(+ "The user's project working directory is {}. \+ Always use absolute paths under this directory for all file \+ and directory operations. This is the root of the project \+ the user has open in their editor.",+ cwd.display()+ );+ if let Some(project_instructions) = instructions::load_project_instructions(cwd) {+ message.push_str("\n\n");+ message.push_str(&project_instructions);+ }+ message+}+ fn agent_tools_as_specs() -> Vec<ToolSpec> {- tools::all_tools()+ let mut specs: Vec<ToolSpec> = tools::all_tools() .into_iter() .map(|t| ToolSpec { name: t.name.to_string(), description: t.description.to_string(), parameters_schema: t.parameters_schema.to_string(), })- .collect()+ .collect();++ // Advertise the `skill` tool only when skills are present, so models without+ // any skills installed don't see a dangling capability (Agent Skills format,+ // https://agentskills.io). Discovery metadata lives in the tool description.+ let discovered = skills::discover_skills();+ if !discovered.is_empty() {+ specs.push(ToolSpec {+ name: skills::SKILL_TOOL_NAME.to_string(),+ description: skills::skill_tool_description(&discovered),+ parameters_schema: skills::skill_tool_schema().to_string(),+ });+ }++ specs } fn initialize_meta() -> Meta {@@ -660,6 +695,7 @@ impl SiGitAgent { "model number to switch to (optional)", )), ),+ AvailableCommand::new("skills", "List available Agent Skills"), AvailableCommand::new("load", "Load the selected on-device model"), with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"), AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),@@ -755,13 +791,9 @@ impl SiGitAgent { if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) { self.engine- .push_history(onde::inference::ChatMessage::system(format!(- "The user's project working directory is {}. \- Always use absolute paths under this directory for all file \- and directory operations. This is the root of the project \- the user has open in their editor.",- cwd.display()- )))+ .push_history(onde::inference::ChatMessage::system(+ session_context_message(&cwd),+ )) .await; }@@ -860,13 +892,9 @@ impl SiGitAgent { self.engine.clear_history().await; self.engine- .push_history(onde::inference::ChatMessage::system(format!(- "The user's project working directory is {}. \- Always use absolute paths under this directory for all file \- and directory operations. This is the root of the project \- the user has open in their editor.",- args.cwd.display()- )))+ .push_history(onde::inference::ChatMessage::system(+ session_context_message(&args.cwd),+ )) .await; let config_options = {@@ -908,13 +936,9 @@ impl SiGitAgent { self.engine.clear_history().await; self.engine- .push_history(onde::inference::ChatMessage::system(format!(- "The user's project working directory is {}. \- Always use absolute paths under this directory for all file \- and directory operations. This is the root of the project \- the user has open in their editor.",- args.cwd.display()- )))+ .push_history(onde::inference::ChatMessage::system(+ session_context_message(&args.cwd),+ )) .await; let config_options = {@@ -954,13 +978,9 @@ impl SiGitAgent { self.engine.clear_history().await; self.engine- .push_history(onde::inference::ChatMessage::system(format!(- "The user's project working directory is {}. \- Always use absolute paths under this directory for all file \- and directory operations. This is the root of the project \- the user has open in their editor.",- args.cwd.display()- )))+ .push_history(onde::inference::ChatMessage::system(+ session_context_message(&args.cwd),+ )) .await; let config_options = {@@ -1280,15 +1300,11 @@ impl SiGitAgent { async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> { let cfg = crate::provider::cloud_tier_provider(tier)?; let mut system_prompt = system_prompt_for_model(true).to_string();- // Mirror the cwd guidance the local engine gets at session load, so the- // cloud model also uses absolute paths under the editor's project root.+ // Mirror the cwd guidance and project instruction files the local engine+ // gets at session load, so the cloud model shares the same project context. if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {- system_prompt.push_str(&format!(- "\n\nThe user's project working directory is {}. \- Always use absolute paths under this directory for all file \- and directory operations.",- cwd.display()- ));+ system_prompt.push_str("\n\n");+ system_prompt.push_str(&session_context_message(&cwd)); } let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new( cfg.base_url,@@ -1813,6 +1829,8 @@ enum SlashCommand { Clear, Status, Models(Option<usize>),+ /// List discovered Agent Skills.+ Skills, /// Explicitly load the selected (or default) on-device model. Load, /// `/login <email> <password>` — the raw argument, parsed when executed.@@ -1838,6 +1856,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { "/clear" => SlashCommand::Clear, "/status" => SlashCommand::Status, "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),+ "/skills" => SlashCommand::Skills, "/load" => SlashCommand::Load, "/login" => SlashCommand::Login(argument.map(str::to_string)), "/logout" => SlashCommand::Logout,@@ -1933,6 +1952,7 @@ async fn exec_slash_acp( "/help - show this message\n\ /models - list available models\n\ /models N - switch to model N\n\+ /skills - list available Agent Skills\n\ /load - load the selected on-device model\n\ /login E P - sign in to siGit Code Cloud\n\ /logout - sign out\n\@@ -1975,6 +1995,11 @@ async fn exec_slash_acp( .send_assistant_message(cx, session_id, format_models_list(¤t_model)) .ok(); }+ SlashCommand::Skills => {+ agent+ .send_assistant_message(cx, session_id, skills::format_skills_list())+ .ok();+ } SlashCommand::Models(Some(number)) => { let items = models::build_model_picker_items(); let index = number.saturating_sub(1);@@ -2263,6 +2288,17 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> // channel so the loading-phase plumbing in `chat::run_with` is unchanged. let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();+ // Project instruction files (AGENTS.md / CLAUDE.md) for the launch directory,+ // injected into the system prompt so the TUI shares the same always-on+ // project context the ACP sessions get.+ let project_instructions = std::env::current_dir()+ .ok()+ .and_then(|cwd| instructions::load_project_instructions(&cwd));+ let with_instructions = |base: String| match &project_instructions {+ Some(extra) => format!("{base}\n\n{extra}"),+ None => base,+ };+ // Pick the inference backend: a configured provider if present, else on-device. let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) = match provider::active_provider() {@@ -2280,7 +2316,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> provider.base_url, provider.api_key, provider.model,- Some(SYSTEM_PROMPT.to_string()),+ Some(with_instructions(SYSTEM_PROMPT.to_string())), )) as Arc<dyn InferenceBackend>; (backend, label) }@@ -2288,6 +2324,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> // On-device: do NOT load the local GGUF model implicitly. The user // loads it explicitly with /load (or /models) from the chat, so the // UI comes up immediately without a multi-minute download/load.+ // Project instructions are injected at load time in `chat.rs`. let _ = load_tx.send(Ok(())); let backend = Arc::new(LocalBackend::new(Arc::clone(&engine))) as Arc<dyn InferenceBackend>;
src/skills.rs
+613
new file mode 100644index 0000000..e20d18e--- /dev/null+++ b/src/skills.rs@@ -0,0 +1,613 @@+//! Agent Skills support for siGit Code.+//!+//! Implements the open [Agent Skills](https://agentskills.io) format: a skill is+//! a directory containing a `SKILL.md` file with YAML frontmatter (`name` ++//! `description`, plus optional fields) followed by Markdown instructions. Skills+//! may bundle `scripts/`, `references/`, and `assets/` the agent loads on demand.+//!+//! Loading follows the spec's *progressive disclosure*:+//!+//! 1. **Discovery** — at turn-build time we scan the skill roots and load only+//! each skill's `name` and `description` into the `skill` tool's description,+//! so the model knows what's available for a small context cost.+//! 2. **Activation** — when a task matches, the model calls the `skill` tool with+//! a name; [`activate_skill`] reads the full `SKILL.md` body into context.+//! 3. **Execution** — the model follows the instructions, reading bundled files+//! (under the reported skill directory) with the normal file/command tools.+//!+//! Skills are discovered from, in priority order (earlier wins on name clashes):+//!+//! - `<cwd>/.sigit/skills/` and `<cwd>/.claude/skills/` (project-local)+//! - `$SIGIT_CONFIG_DIR/skills/` (default `~/.config/sigit/skills/`)+//! - `~/.claude/skills/` (shared with the broader ecosystem)++use std::path::{Path, PathBuf};++use serde_json::{Value, json};++/// The agent-facing tool name used to activate a skill.+pub const SKILL_TOOL_NAME: &str = "skill";++/// Hard cap on how many skills we advertise, to bound the tool description size.+const MAX_ADVERTISED_SKILLS: usize = 100;++/// A discovered skill: its identifying metadata plus where it lives on disk.+#[derive(Debug, Clone, PartialEq, Eq)]+pub struct Skill {+ /// The `name` from frontmatter. Lowercase alphanumeric + single hyphens.+ pub name: String,+ /// The `description` from frontmatter: what the skill does and when to use it.+ pub description: String,+ /// Optional `license` field.+ pub license: Option<String>,+ /// Optional `compatibility` field (environment requirements).+ pub compatibility: Option<String>,+ /// The skill's root directory (the one holding `SKILL.md`).+ pub dir: PathBuf,+}++impl Skill {+ /// Absolute path to this skill's `SKILL.md`.+ fn skill_md(&self) -> PathBuf {+ self.dir.join("SKILL.md")+ }+}++/// JSON Schema for the `skill` tool's arguments.+pub fn skill_tool_schema() -> Value {+ json!({+ "type": "object",+ "properties": {+ "name": {+ "type": "string",+ "description": "The name of the skill to activate, exactly as listed in this tool's description."+ }+ },+ "required": ["name"],+ "additionalProperties": false+ })+}++/// Build the `skill` tool description, embedding the discovery list (each skill's+/// `name` and `description`) so the model can decide when to activate one.+pub fn skill_tool_description(skills: &[Skill]) -> String {+ let mut out = String::from(+ "Activate an Agent Skill to load its full instructions into context. \+ Skills are reusable, on-demand capabilities — specialized knowledge and \+ step-by-step workflows packaged as a folder. Only the name and description \+ of each skill are loaded up front; calling this tool with a skill's `name` \+ reads its full instructions (and tells you the skill's directory, so you \+ can read any bundled scripts, references, or assets with the file and \+ command tools). Activate a skill as soon as the user's task matches one of \+ the descriptions below; follow its instructions over your defaults.\n\n\+ Available skills:\n",+ );+ for skill in skills.iter().take(MAX_ADVERTISED_SKILLS) {+ out.push_str("- ");+ out.push_str(&skill.name);+ out.push_str(": ");+ out.push_str(&skill.description);+ out.push('\n');+ }+ out+}++/// Human-readable list of discovered skills, for the `/skills` slash command.+pub fn format_skills_list() -> String {+ let skills = discover_skills();+ if skills.is_empty() {+ return "No skills found. Add a skill folder (with a SKILL.md) under \+ .sigit/skills/ or .claude/skills/ in your project, or under \+ ~/.config/sigit/skills/. See https://agentskills.io."+ .to_string();+ }++ let mut lines = vec![format!("{} skill(s) available:", skills.len())];+ for skill in &skills {+ lines.push(format!("- {}: {}", skill.name, skill.description));+ }+ lines.push(String::new());+ lines.push(+ "I activate a skill automatically when your task matches its description.".to_string(),+ );+ lines.join("\n")+}++/// Execute the `skill` tool: parse the requested name and return the full+/// `SKILL.md` body, prefixed with the skill's directory so relative references+/// (e.g. `scripts/foo.py`, `references/REFERENCE.md`) can be resolved.+pub fn activate_skill(arguments: &str) -> String {+ let args: Value = match serde_json::from_str(arguments) {+ Ok(v) => v,+ Err(err) => return format!("Error: failed to parse arguments: {err}"),+ };++ let name = match args.get("name").and_then(Value::as_str) {+ Some(n) => n.trim(),+ None => return "Error: missing required parameter \"name\"".to_string(),+ };++ // Re-discover so activation always reflects the skills on disk right now.+ let skills = discover_skills();+ let Some(skill) = skills.iter().find(|s| s.name == name) else {+ if skills.is_empty() {+ return format!("Error: no skill named \"{name}\" is available (no skills found).");+ }+ let available = skills+ .iter()+ .map(|s| s.name.as_str())+ .collect::<Vec<_>>()+ .join(", ");+ return format!("Error: no skill named \"{name}\". Available skills: {available}.");+ };++ let body = match read_skill_body(&skill.skill_md()) {+ Ok(body) => body,+ Err(err) => {+ return format!(+ "Error: could not read SKILL.md for \"{name}\" at {}: {err}",+ skill.skill_md().display()+ );+ }+ };++ // Surface the optional metadata so the agent (and user) can sanity-check+ // environment requirements before following the instructions.+ let mut notes = String::new();+ if let Some(compatibility) = &skill.compatibility {+ notes.push_str(&format!("Compatibility: {compatibility}\n"));+ }+ if let Some(license) = &skill.license {+ notes.push_str(&format!("License: {license}\n"));+ }+ if !notes.is_empty() {+ notes.push('\n');+ }++ let dir = skill.dir.display();+ format!(+ "Skill \"{name}\" activated. Its directory is {dir} — resolve any relative \+ file references (scripts/, references/, assets/) against that path. Follow \+ these instructions:\n\n{notes}{body}"+ )+}++/// Discover all valid skills across the known roots. Earlier roots win when two+/// skills share a `name`. The result is sorted by name for stable output.+pub fn discover_skills() -> Vec<Skill> {+ let mut skills: Vec<Skill> = Vec::new();+ let mut seen_names: Vec<String> = Vec::new();++ for root in skill_roots() {+ collect_skills_from_root(&root, &mut skills, &mut seen_names);+ }++ skills.sort_by(|a, b| a.name.cmp(&b.name));+ skills+}++/// The skill directories to scan, in priority order.+fn skill_roots() -> Vec<PathBuf> {+ let mut roots = Vec::new();++ // Project-local skills win over user-global ones.+ if let Ok(cwd) = std::env::current_dir() {+ roots.push(cwd.join(".sigit").join("skills"));+ roots.push(cwd.join(".claude").join("skills"));+ }++ // User-global siGit config dir (honours SIGIT_CONFIG_DIR).+ if let Some(config_dir) = sigit_config_dir() {+ roots.push(config_dir.join("skills"));+ }++ // Shared with the broader Agent Skills ecosystem.+ if let Some(home) = home_dir() {+ roots.push(home.join(".claude").join("skills"));+ }++ roots+}++fn sigit_config_dir() -> Option<PathBuf> {+ if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")+ && !dir.is_empty()+ {+ return Some(PathBuf::from(dir));+ }+ home_dir().map(|home| home.join(".config").join("sigit"))+}++fn home_dir() -> Option<PathBuf> {+ std::env::var("HOME").ok().map(PathBuf::from)+}++/// Scan a single root for skill subdirectories, appending newly-seen skills.+fn collect_skills_from_root(root: &Path, skills: &mut Vec<Skill>, seen_names: &mut Vec<String>) {+ let entries = match std::fs::read_dir(root) {+ Ok(entries) => entries,+ // Most roots won't exist; that's expected, not an error.+ Err(_) => return,+ };++ for entry in entries.flatten() {+ let dir = entry.path();+ if !dir.is_dir() {+ continue;+ }++ let skill_md = dir.join("SKILL.md");+ if !skill_md.is_file() {+ continue;+ }++ let contents = match std::fs::read_to_string(&skill_md) {+ Ok(contents) => contents,+ Err(error) => {+ log::warn!("skipping skill at {}: {error}", skill_md.display());+ continue;+ }+ };++ let skill = match parse_skill(&contents, &dir) {+ Ok(skill) => skill,+ Err(error) => {+ log::warn!("skipping invalid skill at {}: {error}", skill_md.display());+ continue;+ }+ };++ // First root to define a name wins; later duplicates are ignored.+ if seen_names.iter().any(|name| name == &skill.name) {+ log::debug!(+ "skill \"{}\" at {} shadowed by an earlier definition",+ skill.name,+ dir.display()+ );+ continue;+ }++ seen_names.push(skill.name.clone());+ skills.push(skill);+ }+}++/// Parse a `SKILL.md` into a [`Skill`], validating the required fields against+/// the Agent Skills spec. `dir` is the skill's root directory.+fn parse_skill(contents: &str, dir: &Path) -> Result<Skill, String> {+ let frontmatter = extract_frontmatter(contents)+ .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;++ let fields = parse_frontmatter_fields(frontmatter);++ let name = fields+ .iter()+ .find(|(k, _)| k == "name")+ .map(|(_, v)| v.clone())+ .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;+ validate_name(&name)?;++ let description = fields+ .iter()+ .find(|(k, _)| k == "description")+ .map(|(_, v)| v.clone())+ .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;+ if description.is_empty() {+ return Err("`description` must not be empty".to_string());+ }+ if description.chars().count() > 1024 {+ return Err("`description` exceeds the 1024-character limit".to_string());+ }++ // The spec requires `name` to match the parent directory name. Warn but stay+ // lenient — the frontmatter name is the identity used for activation.+ if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str())+ && dir_name != name+ {+ log::warn!(+ "skill name \"{name}\" does not match its directory \"{dir_name}\" at {}",+ dir.display()+ );+ }++ let license = fields+ .iter()+ .find(|(k, _)| k == "license")+ .map(|(_, v)| v.clone())+ .filter(|v| !v.is_empty());+ let compatibility = fields+ .iter()+ .find(|(k, _)| k == "compatibility")+ .map(|(_, v)| v.clone())+ .filter(|v| !v.is_empty());++ Ok(Skill {+ name,+ description,+ license,+ compatibility,+ dir: dir.to_path_buf(),+ })+}++/// Validate the `name` field per the Agent Skills spec: 1-64 chars, lowercase+/// alphanumeric and hyphens only, no leading/trailing or consecutive hyphens.+fn validate_name(name: &str) -> Result<(), String> {+ let len = name.chars().count();+ if len == 0 {+ return Err("`name` must not be empty".to_string());+ }+ if len > 64 {+ return Err("`name` exceeds the 64-character limit".to_string());+ }+ if !name+ .chars()+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')+ {+ return Err("`name` may only contain lowercase letters, digits, and hyphens".to_string());+ }+ if name.starts_with('-') || name.ends_with('-') {+ return Err("`name` must not start or end with a hyphen".to_string());+ }+ if name.contains("--") {+ return Err("`name` must not contain consecutive hyphens".to_string());+ }+ Ok(())+}++/// Extract the YAML frontmatter block from a `SKILL.md`: the text between a+/// leading `---` line and the next `---` line. Returns `None` if absent.+fn extract_frontmatter(contents: &str) -> Option<&str> {+ // Strip an optional UTF-8 BOM and leading blank lines before the opener.+ let trimmed = contents.trim_start_matches('\u{feff}');+ let mut rest = trimmed;+ loop {+ let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());+ let (line, after) = rest.split_at(line_end);+ if line.trim().is_empty() {+ rest = after;+ continue;+ }+ if line.trim() != "---" {+ return None;+ }+ // `after` now begins just past the opening `---` line.+ let body = after;+ let mut search = body;+ let mut offset = 0;+ loop {+ let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());+ let (l, a) = search.split_at(end);+ if l.trim() == "---" {+ return Some(&body[..offset]);+ }+ if a.is_empty() {+ return None;+ }+ offset += end;+ search = a;+ }+ }+}++/// Parse top-level `key: value` scalar pairs from frontmatter, skipping nested+/// mappings (indented lines) and comments. Quoted values are unquoted. We only+/// need scalar metadata (`name`, `description`, `license`, `compatibility`).+fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {+ let mut fields = Vec::new();+ for line in frontmatter.lines() {+ // Indented lines belong to a nested mapping/sequence — skip them.+ if line.starts_with(char::is_whitespace) {+ continue;+ }+ let line = line.trim_end();+ if line.is_empty() || line.starts_with('#') {+ continue;+ }+ let Some((key, value)) = line.split_once(':') else {+ continue;+ };+ let key = key.trim();+ if key.is_empty() {+ continue;+ }+ let value = unquote(value.trim());+ fields.push((key.to_string(), value));+ }+ fields+}++/// Strip a single layer of matching single or double quotes; otherwise return+/// the value unchanged. Also drops a trailing `# comment` on unquoted scalars.+fn unquote(value: &str) -> String {+ if value.len() >= 2 {+ let bytes = value.as_bytes();+ let first = bytes[0];+ let last = bytes[value.len() - 1];+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {+ return value[1..value.len() - 1].to_string();+ }+ }+ value.to_string()+}++/// Read the Markdown body of a `SKILL.md` (everything after the frontmatter),+/// falling back to the whole file if no frontmatter delimiter is found.+fn read_skill_body(skill_md: &Path) -> Result<String, String> {+ let contents = std::fs::read_to_string(skill_md).map_err(|e| e.to_string())?;+ Ok(strip_frontmatter(&contents).trim().to_string())+}++/// Return the content after the frontmatter block, or the whole input if there+/// is no frontmatter.+fn strip_frontmatter(contents: &str) -> &str {+ let trimmed = contents.trim_start_matches('\u{feff}');+ let after_opener = match trimmed.strip_prefix("---") {+ Some(rest) => match rest.strip_prefix('\n') {+ Some(rest) => rest,+ None => return contents,+ },+ None => return contents,+ };+ // Find the closing `---` line.+ let mut search = after_opener;+ let mut offset = 0;+ loop {+ let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());+ let (line, after) = search.split_at(end);+ if line.trim() == "---" {+ return &after_opener[offset + end..];+ }+ if after.is_empty() {+ return contents;+ }+ offset += end;+ search = after;+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use std::fs;++ fn unique_dir(name: &str) -> PathBuf {+ let nanos = std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .unwrap()+ .as_nanos();+ std::env::temp_dir().join(format!("sigit-skills-test-{name}-{nanos}"))+ }++ #[test]+ fn validate_name_accepts_valid_names() {+ assert!(validate_name("pdf-processing").is_ok());+ assert!(validate_name("data-analysis").is_ok());+ assert!(validate_name("code-review").is_ok());+ assert!(validate_name("a").is_ok());+ assert!(validate_name("skill1").is_ok());+ }++ #[test]+ fn validate_name_rejects_invalid_names() {+ assert!(validate_name("").is_err());+ assert!(validate_name("PDF-Processing").is_err());+ assert!(validate_name("-pdf").is_err());+ assert!(validate_name("pdf-").is_err());+ assert!(validate_name("pdf--processing").is_err());+ assert!(validate_name("pdf_processing").is_err());+ assert!(validate_name(&"a".repeat(65)).is_err());+ }++ #[test]+ fn extract_frontmatter_reads_block() {+ let md = "---\nname: foo\ndescription: bar\n---\n\nBody here.\n";+ let fm = extract_frontmatter(md).expect("frontmatter");+ assert!(fm.contains("name: foo"));+ assert!(fm.contains("description: bar"));+ assert!(!fm.contains("Body here"));+ }++ #[test]+ fn extract_frontmatter_none_without_delimiter() {+ assert!(extract_frontmatter("no frontmatter here").is_none());+ assert!(extract_frontmatter("---\nname: foo\n").is_none());+ }++ #[test]+ fn parse_fields_handles_quotes_and_nesting() {+ let fm = "name: pdf-processing\ndescription: \"Extract PDF text\"\nmetadata:\n author: me\n version: \"1.0\"\nlicense: Apache-2.0\n";+ let fields = parse_frontmatter_fields(fm);+ let get = |k: &str| {+ fields+ .iter()+ .find(|(key, _)| key == k)+ .map(|(_, v)| v.clone())+ };+ assert_eq!(get("name").as_deref(), Some("pdf-processing"));+ assert_eq!(get("description").as_deref(), Some("Extract PDF text"));+ assert_eq!(get("license").as_deref(), Some("Apache-2.0"));+ // Nested keys under `metadata:` are skipped.+ assert!(get("author").is_none());+ assert!(get("version").is_none());+ }++ #[test]+ fn parse_skill_requires_name_and_description() {+ let dir = Path::new("/tmp/example-skill");+ assert!(parse_skill("---\ndescription: x\n---\n", dir).is_err());+ assert!(parse_skill("---\nname: x\n---\n", dir).is_err());+ let ok = parse_skill(+ "---\nname: example-skill\ndescription: does things\n---\nbody",+ dir,+ );+ assert!(ok.is_ok());+ let skill = ok.unwrap();+ assert_eq!(skill.name, "example-skill");+ assert_eq!(skill.description, "does things");+ }++ #[test]+ fn strip_frontmatter_returns_body() {+ let md = "---\nname: foo\ndescription: bar\n---\n\n# Heading\n\nText.\n";+ assert_eq!(strip_frontmatter(md).trim(), "# Heading\n\nText.");+ }++ #[test]+ fn strip_frontmatter_passes_through_without_block() {+ assert_eq!(strip_frontmatter("just body"), "just body");+ }++ #[test]+ fn discover_and_activate_roundtrip() {+ let root = unique_dir("roundtrip");+ let skills_root = root.join(".sigit").join("skills");+ let skill_dir = skills_root.join("hello-world");+ fs::create_dir_all(&skill_dir).unwrap();+ fs::write(+ skill_dir.join("SKILL.md"),+ "---\nname: hello-world\ndescription: Say hello. Use when greeting.\n---\n\nGreet the user warmly.\n",+ )+ .unwrap();++ // discover_skills() reads the current directory, so run from `root`.+ let prev = std::env::current_dir().unwrap();+ std::env::set_current_dir(&root).unwrap();++ let skills = discover_skills();+ let found = skills.iter().find(|s| s.name == "hello-world");+ assert!(found.is_some(), "expected to discover hello-world");+ assert_eq!(found.unwrap().description, "Say hello. Use when greeting.");++ let activated = activate_skill(r#"{"name": "hello-world"}"#);+ assert!(activated.contains("Greet the user warmly."));+ assert!(activated.contains("activated"));++ let missing = activate_skill(r#"{"name": "nope"}"#);+ assert!(missing.contains("no skill named"));++ std::env::set_current_dir(prev).unwrap();+ let _ = fs::remove_dir_all(&root);+ }++ #[test]+ fn skill_tool_description_lists_skills() {+ let skills = vec![Skill {+ name: "pdf-processing".to_string(),+ description: "Extract PDF text".to_string(),+ license: None,+ compatibility: None,+ dir: PathBuf::from("/x/pdf-processing"),+ }];+ let desc = skill_tool_description(&skills);+ assert!(desc.contains("Available skills:"));+ assert!(desc.contains("- pdf-processing: Extract PDF text"));+ }++ #[test]+ fn skill_tool_schema_requires_name() {+ let schema = skill_tool_schema();+ assert_eq!(schema["type"], "object");+ assert_eq!(schema["required"][0], "name");+ }+}