Skip to content
Open
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
1 change: 1 addition & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ export interface SkillInfo {
file_path: string;
base_dir: string;
source: "instance" | "workspace";
source_repo?: string;
}

export interface SkillsListResponse {
Expand Down
14 changes: 11 additions & 3 deletions interface/src/routes/AgentSkills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,13 @@ export function AgentSkills({ agentId }: AgentSkillsProps) {
});

const installedSkills = skillsData?.skills ?? [];
const installedSkillNames = new Map(
installedSkills.map((skill) => [skill.name.toLowerCase(), skill.name]),
const installedKeys = new Map(
installedSkills.map((s) => [
s.source_repo
? `${s.source_repo}/${s.name}`.toLowerCase()
: s.name.toLowerCase(),
s.name,
]),
);

// Flatten browse pages or use search results
Expand Down Expand Up @@ -462,7 +467,10 @@ export function AgentSkills({ agentId }: AgentSkillsProps) {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{registrySkills.map((skill) => {
const spec = installSpec(skill);
const installedName = installedSkillNames.get(skill.name.toLowerCase());
const compositeKey = `${skill.source}/${skill.name}`.toLowerCase();
const installedName =
installedKeys.get(compositeKey) ??
installedKeys.get(skill.name.toLowerCase());
const isInstalled = Boolean(installedName);
return (
<RegistrySkillCard
Expand Down
3 changes: 3 additions & 0 deletions src/api/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub(super) struct SkillInfo {
file_path: String,
base_dir: String,
source: String,
#[serde(skip_serializing_if = "Option::is_none")]
source_repo: Option<String>,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -145,6 +147,7 @@ pub(super) async fn list_skills(
crate::skills::SkillSource::Instance => "instance".to_string(),
crate::skills::SkillSource::Workspace => "workspace".to_string(),
},
source_repo: s.source_repo,
})
.collect();

Expand Down
8 changes: 8 additions & 0 deletions src/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct Skill {
pub content: String,
/// Where this skill was loaded from.
pub source: SkillSource,
/// GitHub `owner/repo` that this skill was installed from, if any.
pub source_repo: Option<String>,
}

/// Where a skill was loaded from, used for precedence tracking.
Expand Down Expand Up @@ -213,6 +215,7 @@ impl SkillSet {
file_path: s.file_path.clone(),
base_dir: s.base_dir.clone(),
source: s.source.clone(),
source_repo: s.source_repo.clone(),
})
.collect()
}
Expand All @@ -226,6 +229,7 @@ pub struct SkillInfo {
pub file_path: PathBuf,
pub base_dir: PathBuf,
pub source: SkillSource,
pub source_repo: Option<String>,
}

/// Load all skills from a directory.
Expand Down Expand Up @@ -293,6 +297,7 @@ async fn load_skill(
});

let description = frontmatter.get("description").cloned().unwrap_or_default();
let source_repo = frontmatter.get("source_repo").cloned();

// Resolve {baseDir} template variable in the body
let base_dir_str = base_dir.to_string_lossy();
Expand All @@ -305,6 +310,7 @@ async fn load_skill(
base_dir: base_dir.to_path_buf(),
content,
source,
source_repo,
})
}

Expand Down Expand Up @@ -465,6 +471,7 @@ mod tests {
base_dir: PathBuf::from("/skills/weather"),
content: "# Weather\n\nUse curl.".into(),
source: SkillSource::Instance,
source_repo: None,
},
);

Expand All @@ -487,6 +494,7 @@ mod tests {
base_dir: PathBuf::from("/skills/weather"),
content: "# Weather\n\nUse curl.".into(),
source: SkillSource::Instance,
source_repo: None,
},
);

Expand Down
132 changes: 130 additions & 2 deletions src/skills/installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ pub async fn install_from_github(spec: &str, target_dir: &Path) -> Result<Vec<St
drop(file);

// Extract and install
let installed = extract_and_install(&zip_path, target_dir, skill_path.as_deref()).await?;
let mut source_repo = format!("{owner}/{repo}");
source_repo.retain(|ch| ch != '\n' && ch != '\r');
let installed =
extract_and_install(&zip_path, target_dir, skill_path.as_deref(), Some(&source_repo))
.await?;

tracing::info!(
installed = ?installed,
Expand All @@ -84,7 +88,7 @@ pub async fn install_from_file(skill_file: &Path, target_dir: &Path) -> Result<V
"installing skill from file"
);

let installed = extract_and_install(skill_file, target_dir, None).await?;
let installed = extract_and_install(skill_file, target_dir, None, None).await?;

tracing::info!(
installed = ?installed,
Expand All @@ -102,6 +106,7 @@ async fn extract_and_install(
zip_path: &Path,
target_dir: &Path,
skill_path: Option<&str>,
source_repo: Option<&str>,
) -> Result<Vec<String>> {
let file = std::fs::File::open(zip_path).context("failed to open zip file")?;

Expand Down Expand Up @@ -176,6 +181,32 @@ async fn extract_and_install(
// Copy skill directory
copy_dir_recursive(&skill_dir, &target_skill_dir).await?;

// Write source_repo into SKILL.md frontmatter so we can track provenance
if let Some(repo) = source_repo {
let skill_md = target_skill_dir.join("SKILL.md");
if skill_md.exists() {
match fs::read_to_string(&skill_md).await {
Ok(content) => {
let patched = inject_source_repo(&content, repo);
if let Err(error) = fs::write(&skill_md, patched).await {
tracing::warn!(
skill = %skill_name,
%error,
"failed to write source_repo to SKILL.md"
);
}
}
Err(error) => {
tracing::warn!(
skill = %skill_name,
%error,
"failed to read SKILL.md for source_repo injection"
);
}
}
}
}

installed.push(skill_name.to_string());

tracing::debug!(
Expand All @@ -188,6 +219,45 @@ async fn extract_and_install(
Ok(installed)
}

/// Inject or update `source_repo` in SKILL.md frontmatter.
fn inject_source_repo(content: &str, repo: &str) -> String {
let trimmed = content.trim_start();
let line = format!("source_repo: {repo}");

if !trimmed.starts_with("---") {
// No frontmatter — add one
return format!("---\n{line}\n---\n{content}");
}

let after_opening = &trimmed[3..];
let Some((end_pos, delimiter_len)) = after_opening
.find("\n---\n")
.map(|pos| (pos, 5))
.or_else(|| after_opening.find("\n---").map(|pos| (pos, 4)))
else {
// Malformed frontmatter, prepend
return format!("---\n{line}\n---\n{content}");
};

let fm_block = &after_opening[..end_pos];
let body = &after_opening[end_pos + delimiter_len..];

// Remove any existing source_repo line
let filtered: Vec<&str> = fm_block
.lines()
.filter(|l| {
!l.trim_start()
.starts_with("source_repo:")
})
.collect();

let mut new_fm = filtered.join("\n");
new_fm.push('\n');
new_fm.push_str(&line);

format!("---{new_fm}\n---\n{body}")
}

/// Parse a GitHub spec: `owner/repo` or `owner/repo/skill-name`
fn parse_github_spec(spec: &str) -> Result<(String, String, Option<String>)> {
let parts: Vec<&str> = spec.split('/').collect();
Expand Down Expand Up @@ -308,4 +378,62 @@ mod tests {
assert!(parse_github_spec("invalid").is_err());
assert!(parse_github_spec("too/many/slashes/here").is_err());
}

#[test]
fn test_inject_source_repo_into_existing_frontmatter() {
let content = "---\nname: weather\ndescription: Get weather\n---\n\n# Weather\n";
let result = inject_source_repo(content, "anthropics/skills");
assert!(result.contains("source_repo: anthropics/skills"));
assert!(result.contains("name: weather"));
assert!(result.contains("# Weather"));
// source_repo should be inside the frontmatter delimiters
let after_first = result.splitn(2, "---").nth(1).unwrap();
let fm = after_first.splitn(2, "\n---").next().unwrap();
assert!(fm.contains("source_repo: anthropics/skills"));
}

#[test]
fn test_inject_source_repo_no_frontmatter() {
let content = "# Just markdown\n\nNo frontmatter here.";
let result = inject_source_repo(content, "owner/repo");
assert!(result.starts_with("---\nsource_repo: owner/repo\n---\n"));
assert!(result.contains("# Just markdown"));
}

#[test]
fn test_inject_source_repo_updates_existing() {
let content = "---\nname: weather\nsource_repo: old/repo\ndescription: foo\n---\n\nBody\n";
let result = inject_source_repo(content, "new/repo");
assert!(result.contains("source_repo: new/repo"));
assert!(!result.contains("old/repo"));
// Should only have one source_repo line
assert_eq!(result.matches("source_repo:").count(), 1);
}

#[test]
fn test_inject_source_repo_malformed_frontmatter() {
let content = "---\nname: broken\nno closing delimiter";
let result = inject_source_repo(content, "owner/repo");
// Falls back to prepending new frontmatter
assert!(result.starts_with("---\nsource_repo: owner/repo\n---\n"));
assert!(result.contains("no closing delimiter"));
}

#[test]
fn test_inject_source_repo_preserves_delimiter_body_newline() {
let content = "---\nname: weather\n---\n# Weather\n";
let patched = inject_source_repo(content, "anthropics/skills");
assert!(patched.contains("\n---\n# Weather\n"));
}

#[test]
fn test_inject_source_repo_roundtrip_with_parse() {
use crate::skills::parse_frontmatter;
let content = "---\nname: weather\ndescription: Get weather\n---\n\n# Weather\n";
let patched = inject_source_repo(content, "anthropics/skills");
let (fm, body) = parse_frontmatter(&patched).unwrap();
assert_eq!(fm.get("source_repo").unwrap(), &"anthropics/skills".to_string());
assert_eq!(fm.get("name").unwrap(), &"weather".to_string());
assert!(body.contains("# Weather"));
}
}