Skip to content

Commit f178f2f

Browse files
Merge pull request #72 from coval-ai/feat/aci-4-skills
feat: add explicit-source agent skills
2 parents 1fc25dc + 066afe0 commit f178f2f

5 files changed

Lines changed: 429 additions & 8 deletions

File tree

src/agent_discovery.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ pub struct ResourceManifest {
4747
pub struct SkillsProfile {
4848
pub implemented: bool,
4949
pub source: Option<&'static str>,
50+
pub list_argv: Vec<String>,
51+
pub install_argv: Vec<String>,
5052
}
5153

5254
#[derive(Debug, Serialize)]
@@ -107,7 +109,7 @@ pub fn manifest() -> Manifest {
107109
profiles: Profiles {
108110
discovery: true,
109111
structured_input: true,
110-
skills: false,
112+
skills: true,
111113
},
112114
agent_mode: AgentMode {
113115
argv_prefix: next_actions::argv(std::iter::empty::<&str>()),
@@ -151,8 +153,19 @@ pub fn manifest() -> Manifest {
151153
.collect(),
152154
doctor_argv: next_actions::argv(["agent", "doctor"]),
153155
skills: SkillsProfile {
154-
implemented: false,
156+
implemented: true,
155157
source: None,
158+
list_argv: next_actions::argv(["agent", "skills", "list", "--source", "<path>"]),
159+
install_argv: next_actions::argv([
160+
"agent",
161+
"skills",
162+
"install",
163+
"<skill-id>",
164+
"--source",
165+
"<path>",
166+
"--dest",
167+
"<path>",
168+
]),
156169
},
157170
}
158171
}

src/commands/agent.rs

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,68 @@
11
use anyhow::Result;
2-
use clap::Subcommand;
2+
use clap::{Args, Subcommand};
33
use serde::Serialize;
4+
use std::path::PathBuf;
45

56
use crate::agent_discovery;
67
use crate::client::models::ListParams;
78
use crate::client::{CovalClient, DEFAULT_BASE_URL};
89
use crate::config::Config;
9-
use crate::output::{emit_one, emit_one_with_warnings, AgentWarning, OutputContext};
10+
use crate::output::{
11+
emit_one, emit_one_with_warnings, emit_one_with_warnings_and_actions, AgentWarning, NextAction,
12+
OutputContext,
13+
};
14+
use crate::skills;
1015

1116
#[derive(Subcommand)]
1217
pub enum AgentCommands {
1318
Doctor,
1419
Manifest,
20+
Skills {
21+
#[command(subcommand)]
22+
command: SkillCommands,
23+
},
24+
}
25+
26+
#[derive(Subcommand)]
27+
pub enum SkillCommands {
28+
List(SkillListArgs),
29+
Install(SkillInstallArgs),
30+
}
31+
32+
#[derive(Args)]
33+
pub struct SkillListArgs {
34+
#[arg(long, env = "COVAL_SKILLS_SOURCE")]
35+
source: Option<String>,
36+
#[arg(long, env = "COVAL_SKILLS_DEST")]
37+
dest: Option<PathBuf>,
38+
}
39+
40+
#[derive(Args)]
41+
pub struct SkillInstallArgs {
42+
skill_id: String,
43+
#[arg(long, env = "COVAL_SKILLS_SOURCE")]
44+
source: String,
45+
#[arg(long, env = "COVAL_SKILLS_DEST")]
46+
dest: PathBuf,
47+
#[arg(long)]
48+
force: bool,
1549
}
1650

1751
impl AgentCommands {
1852
pub fn operation(&self) -> &'static str {
1953
match self {
2054
Self::Doctor => "doctor",
2155
Self::Manifest => "manifest",
56+
Self::Skills { command } => command.operation(),
57+
}
58+
}
59+
}
60+
61+
impl SkillCommands {
62+
pub fn operation(&self) -> &'static str {
63+
match self {
64+
Self::List(_) => "skills.list",
65+
Self::Install(_) => "skills.install",
2266
}
2367
}
2468
}
@@ -62,7 +106,7 @@ struct ConnectivityReport {
62106
#[derive(Debug, Serialize)]
63107
struct SkillsReport {
64108
implemented: bool,
65-
source: Option<&'static str>,
109+
source: Option<String>,
66110
}
67111

68112
pub async fn execute(
@@ -82,6 +126,7 @@ pub async fn execute(
82126
emit_one(ctx, "agent", "manifest", &manifest);
83127
Ok(())
84128
}
129+
AgentCommands::Skills { command } => skills_command(command, ctx),
85130
}
86131
}
87132

@@ -157,11 +202,63 @@ async fn doctor(
157202
connectivity,
158203
},
159204
skills: SkillsReport {
160-
implemented: false,
161-
source: None,
205+
implemented: true,
206+
source: std::env::var("COVAL_SKILLS_SOURCE").ok(),
162207
},
163208
};
164209

165210
emit_one_with_warnings(ctx, "agent", "doctor", &report, warnings);
166211
Ok(())
167212
}
213+
214+
fn skills_command(command: SkillCommands, ctx: &OutputContext) -> Result<()> {
215+
match command {
216+
SkillCommands::List(args) => {
217+
let list = skills::list(args.source.as_deref(), args.dest.as_deref())?;
218+
let mut warnings = Vec::new();
219+
let mut next_actions = Vec::new();
220+
if list.source.is_none() {
221+
warnings.push(AgentWarning::new(
222+
"skills_source_required",
223+
"Pass --source or set COVAL_SKILLS_SOURCE to list skills.",
224+
));
225+
} else if list.skills.is_empty() {
226+
warnings.push(AgentWarning::new(
227+
"skills_empty",
228+
"No skills were found in the provided source.",
229+
));
230+
}
231+
if args.dest.is_none() {
232+
next_actions.push(NextAction::new(
233+
"agent.skills.install",
234+
"Install a skill",
235+
crate::next_actions::argv([
236+
"agent",
237+
"skills",
238+
"install",
239+
"<skill-id>",
240+
"--source",
241+
"<path>",
242+
"--dest",
243+
"<path>",
244+
]),
245+
true,
246+
));
247+
}
248+
emit_one_with_warnings_and_actions(
249+
ctx,
250+
"agent",
251+
"skills.list",
252+
&list,
253+
warnings,
254+
next_actions,
255+
);
256+
Ok(())
257+
}
258+
SkillCommands::Install(args) => {
259+
let installed = skills::install(&args.source, &args.dest, &args.skill_id, args.force)?;
260+
emit_one(ctx, "agent", "skills.install", &installed);
261+
Ok(())
262+
}
263+
}
264+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod config;
66
mod input_json;
77
mod next_actions;
88
mod output;
9+
mod skills;
910

1011
use std::process::ExitCode;
1112

src/skills.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
use std::process::Command;
4+
5+
use anyhow::{Context, Result};
6+
use serde::Serialize;
7+
8+
#[derive(Debug, Serialize)]
9+
pub struct SkillSummary {
10+
pub id: String,
11+
pub namespace: String,
12+
pub name: String,
13+
pub path: String,
14+
pub description: Option<String>,
15+
pub installed: Option<bool>,
16+
}
17+
18+
#[derive(Debug, Serialize)]
19+
pub struct SkillList {
20+
pub source: Option<String>,
21+
pub resolved_ref: Option<String>,
22+
pub skills: Vec<SkillSummary>,
23+
}
24+
25+
#[derive(Debug, Serialize)]
26+
pub struct SkillInstall {
27+
pub id: String,
28+
pub source: String,
29+
pub resolved_ref: Option<String>,
30+
pub installed_path: String,
31+
}
32+
33+
pub fn list(source: Option<&str>, dest: Option<&Path>) -> Result<SkillList> {
34+
let Some(source) = source else {
35+
return Ok(SkillList {
36+
source: None,
37+
resolved_ref: None,
38+
skills: Vec::new(),
39+
});
40+
};
41+
reject_remote_source(source)?;
42+
let source_path = PathBuf::from(source);
43+
let root = skills_root(&source_path);
44+
let mut skills = Vec::new();
45+
46+
if root.exists() {
47+
for namespace in read_dirs(&root)? {
48+
for skill in read_dirs(&namespace)? {
49+
let skill_file = skill.join("SKILL.md");
50+
if !skill_file.exists() {
51+
continue;
52+
}
53+
let namespace_name = file_name(&namespace)?;
54+
let skill_name = file_name(&skill)?;
55+
let id = format!("{namespace_name}/{skill_name}");
56+
skills.push(SkillSummary {
57+
id: id.clone(),
58+
namespace: namespace_name,
59+
name: skill_name,
60+
path: skill.display().to_string(),
61+
description: read_description(&skill_file)?,
62+
installed: dest.map(|dest| dest.join(&id).join("SKILL.md").exists()),
63+
});
64+
}
65+
}
66+
}
67+
68+
skills.sort_by(|left, right| left.id.cmp(&right.id));
69+
70+
Ok(SkillList {
71+
source: Some(source_path.display().to_string()),
72+
resolved_ref: git_head(&source_path),
73+
skills,
74+
})
75+
}
76+
77+
pub fn install(source: &str, dest: &Path, id: &str, force: bool) -> Result<SkillInstall> {
78+
reject_remote_source(source)?;
79+
let source_path = PathBuf::from(source);
80+
let root = skills_root(&source_path);
81+
let source_skill = root.join(id);
82+
let skill_file = source_skill.join("SKILL.md");
83+
if !skill_file.exists() {
84+
anyhow::bail!("Skill not found in source: {id}");
85+
}
86+
87+
let target = dest.join(id);
88+
if target.exists() {
89+
if force {
90+
fs::remove_dir_all(&target)?;
91+
} else {
92+
anyhow::bail!("Skill already exists at {}", target.display());
93+
}
94+
}
95+
96+
copy_dir(&source_skill, &target)?;
97+
98+
Ok(SkillInstall {
99+
id: id.to_string(),
100+
source: source_path.display().to_string(),
101+
resolved_ref: git_head(&source_path),
102+
installed_path: target.display().to_string(),
103+
})
104+
}
105+
106+
fn skills_root(source: &Path) -> PathBuf {
107+
let nested = source.join("skills");
108+
if nested.exists() {
109+
nested
110+
} else {
111+
source.to_path_buf()
112+
}
113+
}
114+
115+
fn read_dirs(path: &Path) -> Result<Vec<PathBuf>> {
116+
let mut dirs = Vec::new();
117+
for entry in fs::read_dir(path)? {
118+
let entry = entry?;
119+
if entry.file_type()?.is_dir() {
120+
dirs.push(entry.path());
121+
}
122+
}
123+
dirs.sort();
124+
Ok(dirs)
125+
}
126+
127+
fn file_name(path: &Path) -> Result<String> {
128+
Ok(path
129+
.file_name()
130+
.and_then(|name| name.to_str())
131+
.ok_or_else(|| anyhow::anyhow!("Invalid skill path: {}", path.display()))?
132+
.to_string())
133+
}
134+
135+
fn read_description(path: &Path) -> Result<Option<String>> {
136+
let body = fs::read_to_string(path)?;
137+
let mut in_frontmatter = false;
138+
for line in body.lines() {
139+
let trimmed = line.trim();
140+
if trimmed == "---" {
141+
if in_frontmatter {
142+
break;
143+
}
144+
in_frontmatter = true;
145+
continue;
146+
}
147+
if in_frontmatter {
148+
if let Some(description) = trimmed.strip_prefix("description:") {
149+
return Ok(Some(description.trim().trim_matches('"').to_string()));
150+
}
151+
}
152+
}
153+
Ok(None)
154+
}
155+
156+
fn copy_dir(source: &Path, target: &Path) -> Result<()> {
157+
fs::create_dir_all(target)?;
158+
for entry in fs::read_dir(source)? {
159+
let entry = entry?;
160+
let source_path = entry.path();
161+
let target_path = target.join(entry.file_name());
162+
if entry.file_type()?.is_dir() {
163+
copy_dir(&source_path, &target_path)?;
164+
} else {
165+
fs::copy(&source_path, &target_path)
166+
.with_context(|| format!("failed to copy {}", source_path.display()))?;
167+
}
168+
}
169+
Ok(())
170+
}
171+
172+
fn reject_remote_source(source: &str) -> Result<()> {
173+
if source.starts_with("https://") || source.starts_with("http://") || source.starts_with("git@")
174+
{
175+
anyhow::bail!(
176+
"Remote skill sources are not fetched by this command. Clone and pin the source locally, then pass the local path."
177+
);
178+
}
179+
Ok(())
180+
}
181+
182+
fn git_head(path: &Path) -> Option<String> {
183+
let output = Command::new("git")
184+
.arg("-C")
185+
.arg(path)
186+
.arg("rev-parse")
187+
.arg("HEAD")
188+
.output()
189+
.ok()?;
190+
if output.status.success() {
191+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
192+
} else {
193+
None
194+
}
195+
}

0 commit comments

Comments
 (0)