@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
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 100644 index 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 100644 index 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 100644 index 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(&sections.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(&current_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 100644 index 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"); + } +}
src/tools.rs
+1
index 38d803c..0c1052d 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -253,6 +253,7 @@ pub async fn execute_tool(name: &str, arguments: &str) -> String { "edit_file" => exec_edit_file(arguments), "delete_file" => exec_delete_file(arguments), "run_command" => exec_run_command(arguments), + "skill" => crate::skills::activate_skill(arguments), _ => format!("Unknown tool: {name}"), } }