Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion docs/native-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,70 @@ Place an `AGENTS.md` file in the working directory (`cwd`). It will be prepended
├── .openab/
│ └── agent/
│ └── auth.json
│ └── skills/ ← skill directories
│ └── my-skill/
│ └── SKILL.md
└── (your project files)
```

> **Note:** Skills and MCP servers are NOT supported yet. Only `AGENTS.md` at `cwd` is read. Skills and MCP support are planned for v0.2.
## Skills

openab-agent supports on-demand skills following the [Agent Skills standard](https://agentskills.io). Skills are directories containing a `SKILL.md` with YAML frontmatter.

### Skill Locations

Scanned in order (first occurrence of a name wins):

1. `<working_dir>/.openab/skills/` — project-local skills
2. `~/.openab/agent/skills/` — global skills

### SKILL.md Format

```markdown
---
name: my-skill
description: What this skill does and when to use it
---

# Instructions

Steps the agent should follow when using this skill.
```

### How It Works

1. At session start, openab-agent scans skill directories
2. Skill names and descriptions are injected into the system prompt
3. When a task matches, the agent uses `read` to load the full SKILL.md
4. The agent follows the instructions using its built-in tools (bash, read, write, edit)

### Example

```
.openab/skills/
└── brave-search/
├── SKILL.md
└── search.sh
```

```markdown
---
name: brave-search
description: Web search via Brave Search API. Use when the user needs current information from the web.
---

# Brave Search

## Usage

\`\`\`bash
./search.sh "query"
\`\`\`
```

### Compatibility

Skills written for Pi (`~/.pi/agent/skills/`) or Claude Code (`~/.claude/skills/`) use the same SKILL.md format. Copy or symlink them into `~/.openab/agent/skills/` to reuse.

## Docker

Expand Down
23 changes: 13 additions & 10 deletions openab-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::path::PathBuf;
use tracing::{debug, info};

use crate::llm::{ContentBlock, LlmEvent, LlmProvider, Message, ToolDef};
use crate::skills;
use crate::tools;

const SYSTEM_PROMPT: &str = r#"You are openab-agent, a coding assistant. You help users by reading, writing, and editing files, and running shell commands.
Expand Down Expand Up @@ -55,20 +56,22 @@ impl Agent {
/// Run the agent with a user prompt, executing tool calls until completion.
/// Returns the final text response.
fn build_system_prompt(working_dir: &str) -> String {
let agents_md = std::path::Path::new(working_dir).join("AGENTS.md");
let wd = std::path::Path::new(working_dir);
let agents_md = wd.join("AGENTS.md");
let custom = std::fs::read_to_string(&agents_md).unwrap_or_default();
if custom.is_empty() {

let base = if custom.is_empty() {
SYSTEM_PROMPT.to_string()
} else {
format!(
"{}

---
format!("{}\n\n---\n\n{}", custom.trim(), SYSTEM_PROMPT)
};

{}",
custom.trim(),
SYSTEM_PROMPT
)
let discovered = skills::discover_skills(wd);
if discovered.is_empty() {
base
} else {
info!("loaded {} skill(s)", discovered.len());
format!("{}{}", base, skills::format_skills_prompt(&discovered))
}
}

Expand Down
1 change: 1 addition & 0 deletions openab-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod acp;
mod agent;
mod auth;
mod llm;
mod skills;
mod tools;

use clap::{Parser, Subcommand};
Expand Down
224 changes: 224 additions & 0 deletions openab-agent/src/skills.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
use std::path::{Path, PathBuf};
use tracing::{debug, warn};

/// A discovered skill with its metadata and path.
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
}

/// Scan skill directories and return discovered skills.
/// Scans: working_dir/.openab/skills/ then ~/.openab/agent/skills/
/// First occurrence of a name wins (project-local takes precedence).
pub fn discover_skills(working_dir: &Path) -> Vec<Skill> {
let mut skills = Vec::new();
let mut seen_names = std::collections::HashSet::new();

let dirs = skill_dirs(working_dir);
for dir in &dirs {
if !dir.is_dir() {
continue;
}
debug!("scanning skills in {}", dir.display());
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let skill_md = entry.path().join("SKILL.md");
if !skill_md.exists() {
continue;
}
if let Some(skill) = parse_skill_md(&skill_md) {
if seen_names.contains(&skill.name) {
warn!(name = %skill.name, "duplicate skill, skipping {}", skill_md.display());
continue;
}
seen_names.insert(skill.name.clone());
skills.push(skill);
}
}
}
skills
}

/// Build the skill directories to scan (project-local first, then global).
fn skill_dirs(working_dir: &Path) -> Vec<PathBuf> {
let mut dirs = vec![working_dir.join(".openab/skills")];
if let Ok(home) = std::env::var("HOME") {
dirs.push(PathBuf::from(home).join(".openab/agent/skills"));
}
dirs
}

/// Parse a SKILL.md file, extracting name and description from YAML frontmatter.
fn parse_skill_md(path: &Path) -> Option<Skill> {
let content = std::fs::read_to_string(path).ok()?;
let (name, description) = parse_frontmatter(&content)?;
if description.is_empty() {
warn!("skill at {} has no description, skipping", path.display());
return None;
}
Some(Skill {
name,
description,
path: path.parent()?.to_path_buf(),
})
}

/// Extract name and description from YAML frontmatter delimited by `---`.
fn parse_frontmatter(content: &str) -> Option<(String, String)> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let end = after_first.find("\n---")?;
let frontmatter = &after_first[..end];

let mut name = String::new();
let mut description = String::new();

for line in frontmatter.lines() {
let line = line.trim();
if let Some(val) = line.strip_prefix("name:") {
name = val.trim().trim_matches('"').trim_matches('\'').to_string();
} else if let Some(val) = line.strip_prefix("description:") {
description = val.trim().trim_matches('"').trim_matches('\'').to_string();
}
}

if name.is_empty() {
return None;
}
Some((name, description))
}

/// Format skills as a system prompt section listing available skills.
pub fn format_skills_prompt(skills: &[Skill]) -> String {
if skills.is_empty() {
return String::new();
}
let mut out = String::from("\n\n## Available Skills\n\nThe following skills are available. Use the `read` tool to load the full SKILL.md when you need a skill's instructions.\n\n");
for skill in skills {
out.push_str(&format!(
"- **{}** ({}): {}\n",
skill.name,
skill.path.join("SKILL.md").display(),
skill.description
));
}
out
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;

#[test]
fn parse_frontmatter_valid() {
let content = "---\nname: my-skill\ndescription: Does things\n---\n\n# Instructions\n";
let (name, desc) = parse_frontmatter(content).unwrap();
assert_eq!(name, "my-skill");
assert_eq!(desc, "Does things");
}

#[test]
fn parse_frontmatter_quoted() {
let content = "---\nname: \"web-search\"\ndescription: 'Searches the web'\n---\n";
let (name, desc) = parse_frontmatter(content).unwrap();
assert_eq!(name, "web-search");
assert_eq!(desc, "Searches the web");
}

#[test]
fn parse_frontmatter_missing_name() {
let content = "---\ndescription: No name\n---\n";
assert!(parse_frontmatter(content).is_none());
}

#[test]
fn parse_frontmatter_no_delimiters() {
let content = "# Just markdown\nNo frontmatter here.";
assert!(parse_frontmatter(content).is_none());
}

#[test]
#[ignore] // Integration test: filesystem I/O
fn discover_skills_from_directory() {
let tmp = TempDir::new().unwrap();
let skills_dir = tmp.path().join(".openab/skills/my-skill");
fs::create_dir_all(&skills_dir).unwrap();
fs::write(
skills_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: Test skill\n---\n\n# Usage\nDo stuff.\n",
)
.unwrap();

let skills = discover_skills(tmp.path());
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-skill");
assert_eq!(skills[0].description, "Test skill");
}

#[test]
#[ignore] // Integration test: filesystem I/O
fn discover_skills_skips_no_description() {
let tmp = TempDir::new().unwrap();
let skills_dir = tmp.path().join(".openab/skills/bad-skill");
fs::create_dir_all(&skills_dir).unwrap();
fs::write(
skills_dir.join("SKILL.md"),
"---\nname: bad-skill\ndescription:\n---\n",
)
.unwrap();

let skills = discover_skills(tmp.path());
assert_eq!(skills.len(), 0);
}

#[test]
#[ignore] // Integration test: filesystem I/O
fn discover_skills_deduplicates() {
let tmp = TempDir::new().unwrap();

// Project-local skill
let local_dir = tmp.path().join(".openab/skills/dupe");
fs::create_dir_all(&local_dir).unwrap();
fs::write(
local_dir.join("SKILL.md"),
"---\nname: dupe\ndescription: Local version\n---\n",
)
.unwrap();

// Simulate global by creating another dir and calling parse directly
let skills = discover_skills(tmp.path());
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].description, "Local version");
}

#[test]
fn format_skills_prompt_empty() {
assert_eq!(format_skills_prompt(&[]), "");
}

#[test]
fn format_skills_prompt_includes_path() {
let skills = vec![Skill {
name: "test".to_string(),
description: "A test skill".to_string(),
path: PathBuf::from("/home/agent/.openab/skills/test"),
}];
let prompt = format_skills_prompt(&skills);
assert!(prompt.contains("test"));
assert!(prompt.contains("A test skill"));
assert!(prompt.contains("SKILL.md"));
}
}
Loading