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
8 changes: 3 additions & 5 deletions src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1232,11 +1232,9 @@ fn user_skill_json(s: &crate::local::user_skills::UserSkill) -> Value {
/// coding agents installed on this machine.
async fn list_user_skills() -> ApiResult {
tokio::task::spawn_blocking(|| {
let skills: Vec<Value> = crate::local::user_skills::list()
.iter()
.map(user_skill_json)
.collect();
Json(json!({ "skills": skills }))
let (skills, discovery) = crate::local::user_skills::list();
let skills: Vec<Value> = skills.iter().map(user_skill_json).collect();
Json(json!({ "skills": skills, "discovery": discovery }))
})
.await
.map_err(|e| ApiError::from(anyhow!(e)))
Expand Down
11 changes: 4 additions & 7 deletions src/local/harness/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,10 @@ impl Harness for Antigravity {
Some(dirs::home_dir()?.join(".gemini").join("antigravity-cli"))
}

fn skill_target(&self) -> Option<PathBuf> {
Some(
self.config_home()?
.join("skills")
.join("orx")
.join("SKILL.md"),
)
fn native_skill_roots(&self) -> Vec<PathBuf> {
self.config_home()
.map(|home| vec![home.join("skills")])
.unwrap_or_default()
}

fn skill_shim(&self) -> Option<&'static str> {
Expand Down
11 changes: 4 additions & 7 deletions src/local/harness/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,13 +984,10 @@ impl Harness for ClaudeCode {
Some(native_store::claude_home(NativeStore::Legacy))
}

fn skill_target(&self) -> Option<PathBuf> {
Some(
self.config_home()?
.join("skills")
.join("orx")
.join("SKILL.md"),
)
fn native_skill_roots(&self) -> Vec<PathBuf> {
self.config_home()
.map(|home| vec![home.join("skills")])
.unwrap_or_default()
}

fn skill_shim(&self) -> Option<&'static str> {
Expand Down
10 changes: 10 additions & 0 deletions src/local/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,16 @@ impl Harness for Codex {
Some(native_store::codex_home(NativeStore::Legacy))
}

fn native_skill_roots(&self) -> Vec<PathBuf> {
[
dirs::home_dir().map(|home| home.join(".agents/skills")),
self.config_home().map(|home| home.join("skills")),
]
.into_iter()
.flatten()
.collect()
}

fn skill_target(&self) -> Option<PathBuf> {
// Codex now speaks native SKILL.md skills (`~/.agents/skills/`); the
// legacy `~/.codex/prompts/` path is deprecated and model-invisible. The
Expand Down
9 changes: 2 additions & 7 deletions src/local/harness/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,8 @@ impl Harness for Cursor {
Some(native_store::cursor_home(NativeStore::Legacy))
}

fn skill_target(&self) -> Option<PathBuf> {
Some(
self.config_home()?
.join("skills")
.join("orx")
.join("SKILL.md"),
)
fn native_skill_roots(&self) -> Vec<PathBuf> {
vec![native_store::cursor_skills_dir()]
}

fn skill_shim(&self) -> Option<&'static str> {
Expand Down
40 changes: 27 additions & 13 deletions src/local/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,14 +399,18 @@ pub trait Harness: Send + Sync {

/// The agent's config home (`~/.claude`, `~/.codex`, `~/.config/opencode`,
/// `~/.cursor`). Presence of this dir is how we tell the agent is set up.
/// `None` if this harness has no installable skill.
fn config_home(&self) -> Option<PathBuf> {
None
}

/// Where the skill shim file lands. `None` if not installable.
/// Install the shim in the first native search root. Harnesses may override it.
fn skill_target(&self) -> Option<PathBuf> {
None
Some(
self.native_skill_roots()
.first()?
.join("orx")
.join("SKILL.md"),
)
}

/// The shim file contents to write at `skill_target`. `None` if not
Expand All @@ -429,18 +433,12 @@ pub trait Harness: Send + Sync {
self.config_home().map(|h| h.exists()).unwrap_or(false)
}

/// The agent's global native-skills dir (`~/.claude/skills`,
/// `~/.agents/skills`, `<xdg>/opencode/skills`) — where the user's own
/// installed skills live. Derived from the `skills/orx/SKILL.md` shim
/// target every installable harness shares. `None` if not installable.
fn global_skills_dir(&self) -> Option<PathBuf> {
Some(self.skill_target()?.parent()?.parent()?.to_path_buf())
/// Native skill search roots, in precedence order, independent of session state.
fn native_skill_roots(&self) -> Vec<PathBuf> {
Vec::new()
}

/// Further skills dirs this agent loads from, each labeled by where it came
/// from — the plugins installed into this agent. Same shape as
/// [`global_skills_dir`](Self::global_skills_dir): one skill folder per
/// entry. Default: none.
/// Installed plugin skill roots, labeled with their invocation namespace.
fn plugin_skills_dirs(&self) -> Vec<(String, PathBuf)> {
Vec::new()
}
Expand Down Expand Up @@ -848,6 +846,22 @@ mod tests {
use super::options::{PlanActivation, REASONING_DEFAULT_ID};
use super::*;

#[test]
fn skill_install_targets_are_declared_search_roots() {
for harness in registry() {
if let Some(target) = harness.skill_target() {
assert!(
harness
.native_skill_roots()
.iter()
.any(|root| target.starts_with(root)),
"{} skill target is not discoverable",
harness.id()
);
}
}
}

#[test]
fn compaction_support_is_explicit_for_builtins_and_disabled_for_acp() {
let supported: [&dyn Harness; 5] = [
Expand Down
11 changes: 4 additions & 7 deletions src/local/harness/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,13 +452,10 @@ impl Harness for OpenCode {
Some(super::xdg_config_home().join("opencode"))
}

fn skill_target(&self) -> Option<PathBuf> {
Some(
self.config_home()?
.join("skills")
.join("orx")
.join("SKILL.md"),
)
fn native_skill_roots(&self) -> Vec<PathBuf> {
self.config_home()
.map(|home| vec![home.join("skills")])
.unwrap_or_default()
}

fn skill_shim(&self) -> Option<&'static str> {
Expand Down
40 changes: 38 additions & 2 deletions src/local/native_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ pub fn cursor_home(store: NativeStore) -> PathBuf {
}
}

pub fn cursor_skills_dir() -> PathBuf {
cursor_skills_dir_in(&home_dir(), user_env_path("CURSOR_CONFIG_DIR"))
}

fn cursor_skills_dir_in(home: &Path, config: Option<PathBuf>) -> PathBuf {
// XDG redirects Cursor state, but is not a native skill search location.
config
.unwrap_or_else(|| home.join(".cursor"))
.join("skills")
}

pub fn opencode_session(native_id: &str) -> Result<Option<NativeSessionLocation>> {
let isolated = opencode_db(NativeStore::Isolated);
let legacy = opencode_db(NativeStore::Legacy);
Expand Down Expand Up @@ -756,7 +767,7 @@ pub fn prepare_cursor(store: NativeStore) -> Result<PathBuf> {
&legacy,
&[
legacy.join("cli-config.json"),
legacy.join("skills"),
cursor_skills_dir(),
legacy.join("skills-cursor"),
legacy.join("plugins"),
],
Expand Down Expand Up @@ -802,7 +813,8 @@ fn reconcile_link(source: &Path, destination: &Path) -> Result<()> {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(destination)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
&& std::fs::read_link(destination).is_ok_and(|target| target == source)
&& (marker.is_file()
|| std::fs::read_link(destination).is_ok_and(|target| target == source))
{
remove_link(destination)?;
}
Expand Down Expand Up @@ -989,6 +1001,30 @@ fn create_junction(source: &Path, destination: &Path) -> std::io::Result<()> {
mod hard_link_tests {
use super::*;

#[test]
fn cursor_roots_and_retired_managed_links() {
let root = std::env::temp_dir().join(format!("orx-cursor-skills-{}", uuid::Uuid::new_v4()));
let native = cursor_skills_dir_in(&root, None);
assert_eq!(native, root.join(".cursor/skills"));
assert_eq!(
cursor_skills_dir_in(&root, Some(root.join("custom"))),
root.join("custom/skills")
);
let old = root.join("xdg/cursor/skills");
let isolated = root.join("isolated");
std::fs::create_dir_all(&old).unwrap();
prepare_links(&isolated, &root, std::slice::from_ref(&old)).unwrap();
let link = isolated.join("skills");
assert!(same_file::is_same_file(&link, &old).unwrap());
reconcile_link(&native, &link).unwrap();
assert!(std::fs::symlink_metadata(&link).is_err());
assert!(old.is_dir());
create_symlink(&old, &link).unwrap();
reconcile_link(&native, &link).unwrap();
assert!(same_file::is_same_file(&link, &old).unwrap());
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn a_hard_linked_destination_survives_an_edit_through_the_source() {
let root = std::env::temp_dir().join(format!("orx-hard-link-{}", uuid::Uuid::new_v4()));
Expand Down
Loading
Loading