Skip to content

Commit bceeef3

Browse files
rexleimoclaude
andcommitted
chore: add skills system and ollama smoke test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent af3dc5e commit bceeef3

13 files changed

Lines changed: 893 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ resolver = "2"
33
members = [
44
"crates/rexos",
55
"crates/loopforge-cli",
6+
"crates/rexos-skills",
67
"crates/rexos-daemon",
78
"crates/rexos-harness",
89
"crates/rexos-kernel",
@@ -29,6 +30,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
2930
rusqlite = { version = "0.31", features = ["bundled"] }
3031
serde = { version = "1", features = ["derive"] }
3132
serde_json = "1"
33+
semver = { version = "1", features = ["serde"] }
3234
sha2 = "0.10"
3335
tempfile = "3"
3436
thiserror = "2"

crates/rexos-kernel/src/paths.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ impl RexosPaths {
2323
self.base_dir.join("rexos.db")
2424
}
2525

26+
pub fn workspace_skills_dir(workspace_root: &Path) -> PathBuf {
27+
workspace_root.join(".loopforge/skills")
28+
}
29+
30+
pub fn workspace_legacy_skills_dir(workspace_root: &Path) -> PathBuf {
31+
workspace_root.join(".rexos/skills")
32+
}
33+
34+
pub fn codex_home_skills_dir(home_dir: &Path) -> PathBuf {
35+
home_dir.join(".codex/skills")
36+
}
37+
2638
pub fn ensure_dirs(&self) -> anyhow::Result<()> {
2739
std::fs::create_dir_all(&self.base_dir)
2840
.with_context(|| format!("create base dir: {}", self.base_dir.display()))?;
@@ -48,5 +60,23 @@ mod tests {
4860
assert!(paths.is_inside_base(&paths.base_dir.join("a/b/c")));
4961
assert!(!paths.is_inside_base(Path::new("/tmp/not-rexos")));
5062
}
51-
}
5263

64+
#[test]
65+
fn skills_paths_follow_expected_layout() {
66+
let workspace = Path::new("/tmp/workspace");
67+
let home = Path::new("/tmp/home");
68+
69+
assert_eq!(
70+
RexosPaths::workspace_skills_dir(workspace),
71+
PathBuf::from("/tmp/workspace/.loopforge/skills")
72+
);
73+
assert_eq!(
74+
RexosPaths::workspace_legacy_skills_dir(workspace),
75+
PathBuf::from("/tmp/workspace/.rexos/skills")
76+
);
77+
assert_eq!(
78+
RexosPaths::codex_home_skills_dir(home),
79+
PathBuf::from("/tmp/home/.codex/skills")
80+
);
81+
}
82+
}

crates/rexos-skills/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "rexos-skills"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
rust-version.workspace = true
7+
8+
[dependencies]
9+
anyhow.workspace = true
10+
serde.workspace = true
11+
toml.workspace = true
12+
semver.workspace = true
13+
14+
[dev-dependencies]
15+
tempfile.workspace = true

crates/rexos-skills/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod loader;
2+
pub mod manifest;
3+
pub mod resolver;

crates/rexos-skills/src/loader.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
use std::collections::BTreeMap;
2+
use std::path::{Path, PathBuf};
3+
4+
use crate::manifest::{SkillManifest, parse_manifest};
5+
6+
const SKILL_MANIFEST_FILE: &str = "skill.toml";
7+
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+
pub enum SkillSource {
10+
Home,
11+
WorkspaceLegacy,
12+
Workspace,
13+
}
14+
15+
#[derive(Debug, Clone)]
16+
pub struct DiscoveredSkill {
17+
pub name: String,
18+
pub root_dir: PathBuf,
19+
pub manifest_path: PathBuf,
20+
pub source: SkillSource,
21+
pub manifest: SkillManifest,
22+
}
23+
24+
pub fn discover_skills(
25+
workspace_root: &Path,
26+
home_skills_root: &Path,
27+
) -> anyhow::Result<BTreeMap<String, DiscoveredSkill>> {
28+
let mut discovered = BTreeMap::new();
29+
30+
// Lower precedence first, later inserts override.
31+
let roots = [
32+
(SkillSource::Home, home_skills_root.to_path_buf()),
33+
(
34+
SkillSource::WorkspaceLegacy,
35+
workspace_root.join(".rexos/skills"),
36+
),
37+
(SkillSource::Workspace, workspace_root.join(".loopforge/skills")),
38+
];
39+
40+
for (source, root) in roots {
41+
discover_under_root(&root, source, &mut discovered)?;
42+
}
43+
44+
Ok(discovered)
45+
}
46+
47+
fn discover_under_root(
48+
root: &Path,
49+
source: SkillSource,
50+
out: &mut BTreeMap<String, DiscoveredSkill>,
51+
) -> anyhow::Result<()> {
52+
if !root.is_dir() {
53+
return Ok(());
54+
}
55+
56+
for entry in std::fs::read_dir(root)? {
57+
let entry = entry?;
58+
if !entry.file_type()?.is_dir() {
59+
continue;
60+
}
61+
62+
let skill_root = entry.path();
63+
let manifest_path = skill_root.join(SKILL_MANIFEST_FILE);
64+
if !manifest_path.is_file() {
65+
continue;
66+
}
67+
68+
let raw = match std::fs::read_to_string(&manifest_path) {
69+
Ok(raw) => raw,
70+
Err(_) => continue,
71+
};
72+
73+
let manifest = match parse_manifest(&raw) {
74+
Ok(manifest) => manifest,
75+
Err(_) => continue,
76+
};
77+
let name = manifest.name.clone();
78+
out.insert(
79+
name.clone(),
80+
DiscoveredSkill {
81+
name,
82+
root_dir: skill_root,
83+
manifest_path,
84+
source,
85+
manifest,
86+
},
87+
);
88+
}
89+
90+
Ok(())
91+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
use anyhow::bail;
2+
use serde::Deserialize;
3+
4+
pub fn parse_manifest(raw: &str) -> anyhow::Result<SkillManifest> {
5+
let parsed: SkillManifestRaw = toml::from_str(raw)?;
6+
7+
let name = parsed.name.trim().to_string();
8+
if name.is_empty() {
9+
bail!("name cannot be empty");
10+
}
11+
12+
let entry = parsed.entry.trim().to_string();
13+
if entry.is_empty() {
14+
bail!("entry cannot be empty");
15+
}
16+
17+
let permissions = parsed
18+
.permissions
19+
.into_iter()
20+
.map(|p| p.trim().to_string())
21+
.filter(|p| !p.is_empty())
22+
.collect::<Vec<_>>();
23+
24+
let mut dependencies = Vec::with_capacity(parsed.dependencies.len());
25+
for dep in parsed.dependencies {
26+
let dep_name = dep.name.trim().to_string();
27+
if dep_name.is_empty() {
28+
bail!("dependency.name cannot be empty");
29+
}
30+
let version_req = dep
31+
.version_req
32+
.or(dep.version)
33+
.unwrap_or(semver::VersionReq::STAR);
34+
dependencies.push(SkillDependency {
35+
name: dep_name,
36+
version_req,
37+
});
38+
}
39+
40+
Ok(SkillManifest {
41+
name,
42+
version: parsed.version,
43+
entry,
44+
permissions,
45+
dependencies,
46+
})
47+
}
48+
49+
#[derive(Debug, Clone, PartialEq, Eq)]
50+
pub struct SkillManifest {
51+
pub name: String,
52+
pub version: semver::Version,
53+
pub entry: String,
54+
pub permissions: Vec<String>,
55+
pub dependencies: Vec<SkillDependency>,
56+
}
57+
58+
#[derive(Debug, Clone, PartialEq, Eq)]
59+
pub struct SkillDependency {
60+
pub name: String,
61+
pub version_req: semver::VersionReq,
62+
}
63+
64+
#[derive(Debug, Clone, Deserialize)]
65+
struct SkillManifestRaw {
66+
name: String,
67+
version: semver::Version,
68+
entry: String,
69+
#[serde(default)]
70+
permissions: Vec<String>,
71+
#[serde(default)]
72+
dependencies: Vec<SkillDependencyRaw>,
73+
}
74+
75+
#[derive(Debug, Clone, Deserialize)]
76+
struct SkillDependencyRaw {
77+
name: String,
78+
#[serde(default)]
79+
version_req: Option<semver::VersionReq>,
80+
#[serde(default)]
81+
version: Option<semver::VersionReq>,
82+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
use std::collections::{BTreeSet, HashMap};
2+
3+
use anyhow::bail;
4+
5+
#[derive(Debug, Clone, PartialEq, Eq)]
6+
pub struct SkillNode {
7+
pub name: String,
8+
pub version: semver::Version,
9+
pub dependencies: Vec<SkillDependencyConstraint>,
10+
}
11+
12+
#[derive(Debug, Clone, PartialEq, Eq)]
13+
pub struct SkillDependencyConstraint {
14+
pub name: String,
15+
pub version_req: semver::VersionReq,
16+
}
17+
18+
pub fn resolve_load_order(nodes: Vec<SkillNode>) -> anyhow::Result<Vec<String>> {
19+
if nodes.is_empty() {
20+
return Ok(Vec::new());
21+
}
22+
23+
let mut by_name: HashMap<String, SkillNode> = HashMap::new();
24+
for node in nodes {
25+
let name = node.name.trim().to_string();
26+
if name.is_empty() {
27+
bail!("skill name cannot be empty");
28+
}
29+
if by_name.insert(name.clone(), node).is_some() {
30+
bail!("duplicate skill definition: {name}");
31+
}
32+
}
33+
34+
let mut indegree: HashMap<String, usize> = HashMap::new();
35+
let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
36+
37+
for (name, node) in &by_name {
38+
indegree.entry(name.clone()).or_insert(0);
39+
40+
for dep in &node.dependencies {
41+
let dep_name = dep.name.trim();
42+
if dep_name.is_empty() {
43+
bail!("dependency name cannot be empty (skill={name})");
44+
}
45+
46+
let Some(dep_node) = by_name.get(dep_name) else {
47+
bail!("missing dependency: skill={name}, dependency={dep_name}");
48+
};
49+
50+
if !dep.version_req.matches(&dep_node.version) {
51+
bail!(
52+
"dependency version mismatch: skill={name}, dependency={dep_name}, requires={}, got={}",
53+
dep.version_req,
54+
dep_node.version
55+
);
56+
}
57+
58+
outgoing
59+
.entry(dep_name.to_string())
60+
.or_default()
61+
.push(name.clone());
62+
*indegree.entry(name.clone()).or_insert(0) += 1;
63+
}
64+
}
65+
66+
let mut ready = BTreeSet::new();
67+
for (name, degree) in &indegree {
68+
if *degree == 0 {
69+
ready.insert(name.clone());
70+
}
71+
}
72+
73+
let mut order = Vec::with_capacity(by_name.len());
74+
while let Some(name) = ready.pop_first() {
75+
order.push(name.clone());
76+
77+
if let Some(next_skills) = outgoing.get(&name) {
78+
for next in next_skills {
79+
if let Some(entry) = indegree.get_mut(next) {
80+
*entry -= 1;
81+
if *entry == 0 {
82+
ready.insert(next.clone());
83+
}
84+
}
85+
}
86+
}
87+
}
88+
89+
if order.len() != by_name.len() {
90+
bail!("dependency cycle detected in skills graph");
91+
}
92+
93+
Ok(order)
94+
}

0 commit comments

Comments
 (0)