From f33928a8ff737d9a7d7e0d8c9e12c07be2bf58d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 03:33:22 +0000 Subject: [PATCH] Add Anthropic skills source Co-authored-by: Gao Yu --- .../src/anthropic-skills-index.json | 40 +++ crates/agent-core/src/config.rs | 36 ++ crates/agent-core/src/core.rs | 122 +++++-- crates/agent-core/src/skills.rs | 330 ++++++++++++++++++ docs/skills.md | 29 +- 5 files changed, 534 insertions(+), 23 deletions(-) create mode 100644 crates/agent-core/src/anthropic-skills-index.json diff --git a/crates/agent-core/src/anthropic-skills-index.json b/crates/agent-core/src/anthropic-skills-index.json new file mode 100644 index 0000000..0aa7d2a --- /dev/null +++ b/crates/agent-core/src/anthropic-skills-index.json @@ -0,0 +1,40 @@ +{ + "name": "anthropic", + "repository": "https://github.com/anthropics/skills", + "revision": "3b3fad96af16a10759d930941b4520ba0c40edae", + "skills": [ + { "id": "academy-guide", "path": "skills/academy-guide/SKILL.md" }, + { "id": "algorithmic-art", "path": "skills/algorithmic-art/SKILL.md" }, + { "id": "brand-guidelines", "path": "skills/brand-guidelines/SKILL.md" }, + { "id": "canvas-design", "path": "skills/canvas-design/SKILL.md" }, + { "id": "claude-api", "path": "skills/claude-api/SKILL.md" }, + { "id": "discernment-nudge", "path": "skills/discernment-nudge/SKILL.md" }, + { "id": "doc-coauthoring", "path": "skills/doc-coauthoring/SKILL.md" }, + { "id": "frontend-design", "path": "skills/frontend-design/SKILL.md" }, + { "id": "internal-comms", "path": "skills/internal-comms/SKILL.md" }, + { "id": "mcp-builder", "path": "skills/mcp-builder/SKILL.md" }, + { "id": "skill-creator", "path": "skills/skill-creator/SKILL.md" }, + { "id": "slack-gif-creator", "path": "skills/slack-gif-creator/SKILL.md" }, + { "id": "theme-factory", "path": "skills/theme-factory/SKILL.md" }, + { "id": "web-artifacts-builder", "path": "skills/web-artifacts-builder/SKILL.md" }, + { "id": "webapp-testing", "path": "skills/webapp-testing/SKILL.md" } + ], + "excluded": [ + { + "id": "docx", + "reason": "source-available document skill; not redistributed by JuCode" + }, + { + "id": "pdf", + "reason": "source-available document skill; not redistributed by JuCode" + }, + { + "id": "pptx", + "reason": "source-available document skill; not redistributed by JuCode" + }, + { + "id": "xlsx", + "reason": "source-available document skill; not redistributed by JuCode" + } + ] +} diff --git a/crates/agent-core/src/config.rs b/crates/agent-core/src/config.rs index b6bc14e..97892ea 100644 --- a/crates/agent-core/src/config.rs +++ b/crates/agent-core/src/config.rs @@ -195,6 +195,9 @@ pub struct Config { /// (`enable_browser_open` in config.json, default true). It is only ever /// exposed when running under JuCode Desktop (JUCODE_DESKTOP set). pub enable_browser_open: bool, + /// Optional additional GitHub skill repository. "anthropic" selects the + /// pinned built-in index for https://github.com/anthropics/skills. + pub extra_skills_source: Option, pub extensions: Vec, pub mcp_servers: Vec, path: PathBuf, @@ -333,6 +336,7 @@ impl Config { approval_mode: ApprovalMode::default(), edit_tools: default_edit_tools(), enable_browser_open: true, + extra_skills_source: None, extensions: Vec::new(), mcp_servers: Vec::new(), path, @@ -415,6 +419,7 @@ impl Config { approval_mode: read_approval_mode(&value)?, edit_tools: read_edit_tools(&value)?, enable_browser_open: read_bool(&value, "enable_browser_open", true), + extra_skills_source: read_optional_string(&value, "extra_skills_source"), extensions: read_extensions(&value), mcp_servers: read_mcp_servers(&value), path, @@ -449,6 +454,7 @@ impl Config { "approval_mode": self.approval_mode.as_str(), "edit_tools": self.edit_tools, "enable_browser_open": self.enable_browser_open, + "extra_skills_source": self.extra_skills_source, "extensions": self.extensions.iter().map(extension_config_value).collect::>(), "mcp_servers": self.mcp_servers.iter().map(mcp_server_config_value).collect::>() }); @@ -605,6 +611,15 @@ fn read_string(value: &Value, key: &str, default: &str) -> String { .to_string() } +fn read_optional_string(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + fn read_usize(value: &Value, key: &str, default: usize) -> usize { value .get(key) @@ -1309,6 +1324,7 @@ mod tests { approval_mode: ApprovalMode::default(), edit_tools: default_edit_tools(), enable_browser_open: true, + extra_skills_source: None, extensions: Vec::new(), mcp_servers: Vec::new(), path: PathBuf::from("config.json"), @@ -1536,6 +1552,26 @@ mod tests { assert!(error.to_string().contains("array")); } + #[test] + fn extra_skills_source_is_optional_and_trimmed() { + assert_eq!( + read_optional_string(&json!({}), "extra_skills_source"), + None + ); + assert_eq!( + read_optional_string( + &json!({ "extra_skills_source": " anthropic " }), + "extra_skills_source" + ) + .as_deref(), + Some("anthropic") + ); + assert_eq!( + read_optional_string(&json!({ "extra_skills_source": "" }), "extra_skills_source"), + None + ); + } + #[test] fn canonical_edit_tool_name_covers_aliases_and_rejects_others() { assert_eq!(canonical_edit_tool_name("edit"), Some("str_replace")); diff --git a/crates/agent-core/src/core.rs b/crates/agent-core/src/core.rs index a99ae37..681f4ab 100644 --- a/crates/agent-core/src/core.rs +++ b/crates/agent-core/src/core.rs @@ -712,11 +712,9 @@ impl AgentCore { Err(error) => format!("Project skills: failed to read ({error})"), } }; - match self.fetch_marketplace() { + let marketplace = match self.fetch_marketplace() { Ok(marketplace) if marketplace.skills.is_empty() => { - vec![AgentEvent::Info(format!( - "{installed}\n\n{project}\n\nSkills marketplace is empty" - ))] + "Source: JuCode marketplace\nNo skills available".to_string() } Ok(marketplace) => { let defaults = marketplace @@ -733,15 +731,49 @@ impl AgentCore { }; lines.push(format!("{}{} — {}", skill.id, marker, skill.description)); } - vec![AgentEvent::Info(format!( - "{installed}\n\n{project}\n\nAvailable marketplace skills:\n{}\n\nInstall with /skills install ; update with /skills update ; sync defaults with /skills sync.", - lines.join("\n") - ))] + format!("Source: JuCode marketplace\n{}", lines.join("\n")) } - Err(error) => vec![AgentEvent::Info(format!( - "{installed}\n\n{project}\n\nMarketplace unavailable: {error}" - ))], - } + Err(error) => format!("Source: JuCode marketplace (unavailable: {error})"), + }; + let extra = match self.fetch_extra_skill_source() { + Ok(Some(source)) => { + let mut lines = source + .skills + .iter() + .map(|skill| skill.id.clone()) + .collect::>(); + if !source.excluded.is_empty() { + lines.push(format!( + "Not offered: {}", + source + .excluded + .iter() + .map(|skill| format!("{} ({})", skill.id, skill.reason)) + .collect::>() + .join(", ") + )); + } + Some(format!( + "Source: {} ({})\n{}", + source.name, + source.repository, + if lines.is_empty() { + "No skills available".to_string() + } else { + lines.join("\n") + } + )) + } + Ok(None) => None, + Err(error) => Some(format!("Extra skills source unavailable: {error}")), + }; + let mut sections = vec![installed, project, marketplace]; + sections.extend(extra); + sections.push( + "Install with /skills install ; update with /skills update ; sync JuCode defaults with /skills sync." + .to_string(), + ); + vec![AgentEvent::Info(sections.join("\n\n"))] } fn install_marketplace_skill_events(&mut self, id: &str, verb: &str) -> Vec { @@ -750,26 +782,63 @@ impl AgentCore { "installed skill not found: {id}" ))]; } - match self.fetch_marketplace() { - Ok(marketplace) => { - let Some(skill) = marketplace.skills.iter().find(|skill| skill.id == id) else { - return vec![AgentEvent::Error(format!( - "marketplace skill not found: {id}" - ))]; - }; - match skills::install_marketplace_skill(self.config.profile_dir(), skill) { + let marketplace = self.fetch_marketplace(); + if let Ok(marketplace) = &marketplace { + if let Some(skill) = marketplace.skills.iter().find(|skill| skill.id == id) { + return match skills::install_marketplace_skill(self.config.profile_dir(), skill) { Ok(()) => vec![ - AgentEvent::Status(format!("{verb} skill {}", skill.id)), + AgentEvent::Status(format!( + "{verb} skill {} from JuCode marketplace", + skill.id + )), self.command_list_event(), ], Err(error) => vec![AgentEvent::Error(format!( "failed to install skill {}: {error}", skill.id ))], + }; + } + } + match self.fetch_extra_skill_source() { + Ok(Some(source)) => { + if let Some(skill) = source.skills.iter().find(|skill| skill.id == id) { + return match skills::install_extra_skill( + self.config.profile_dir(), + &source, + skill, + ) { + Ok(()) => vec![ + AgentEvent::Status(format!( + "{verb} skill {} from {}", + skill.id, source.name + )), + self.command_list_event(), + ], + Err(error) => vec![AgentEvent::Error(format!( + "failed to install skill {} from {}: {error}", + skill.id, source.name + ))], + }; + } + if let Some(excluded) = source.excluded.iter().find(|skill| skill.id == id) { + return vec![AgentEvent::Error(format!( + "skill {} is not offered by {}: {}", + excluded.id, source.name, excluded.reason + ))]; } } + Ok(None) => {} + Err(error) => { + return vec![AgentEvent::Error(format!( + "failed to load extra skills source: {error}" + ))]; + } + } + match marketplace { + Ok(_) => vec![AgentEvent::Error(format!("skill not found in configured sources: {id}"))], Err(error) => vec![AgentEvent::Error(format!( - "failed to fetch skills marketplace: {error}" + "skill not found in configured extra source and JuCode marketplace is unavailable: {error}" ))], } } @@ -836,6 +905,15 @@ impl AgentCore { skills::fetch_marketplace(&self.config.jucode_api_url, self.auth.jucode_access_token()) } + fn fetch_extra_skill_source(&self) -> Result, String> { + skills::fetch_extra_skill_source( + self.config + .extra_skills_source + .as_deref() + .unwrap_or_default(), + ) + } + /// Returns the bearer token for the active provider: the JuCode OAuth /// access token for the jucode provider, otherwise the raw provider key. fn provider_api_key(&self) -> Option<&str> { diff --git a/crates/agent-core/src/skills.rs b/crates/agent-core/src/skills.rs index 66210f6..87af783 100644 --- a/crates/agent-core/src/skills.rs +++ b/crates/agent-core/src/skills.rs @@ -14,6 +14,30 @@ const MAX_PACKAGE_BYTES: usize = 20 * 1024 * 1024; const MAX_EXTRACTED_BYTES: u64 = 100 * 1024 * 1024; const MAX_PACKAGE_FILES: usize = 4096; const SKILL_STATE_FILE: &str = "skills-state.json"; +pub const ANTHROPIC_SKILLS_URL: &str = "https://github.com/anthropics/skills"; +const ANTHROPIC_SKILLS_INDEX: &str = include_str!("anthropic-skills-index.json"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtraSkill { + pub id: String, + pub path: String, + pub sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExcludedSkill { + pub id: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtraSkillSource { + pub name: String, + pub repository: String, + pub revision: String, + pub skills: Vec, + pub excluded: Vec, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplaceSkill { @@ -48,6 +72,171 @@ pub fn fetch_marketplace(api_url: &str, api_key: Option<&str>) -> Result Result, String> { + let spec = spec.trim(); + if spec.is_empty() { + return Ok(None); + } + if spec == "anthropic" || normalize_repository_url(spec) == ANTHROPIC_SKILLS_URL { + let value = serde_json::from_str(ANTHROPIC_SKILLS_INDEX) + .map_err(|error| format!("invalid bundled Anthropic skills index: {error}"))?; + return parse_extra_skill_index(&value).map(Some); + } + + let (owner, repository) = github_repository_parts(spec)?; + let api_url = format!("https://api.github.com/repos/{owner}/{repository}/contents/skills"); + let response = ureq::get(&api_url) + .set("Accept", "application/vnd.github+json") + .set("User-Agent", "jucode-cli") + .timeout(std::time::Duration::from_secs(30)) + .call() + .map_err(|error| error.to_string())?; + let value = response + .into_json::() + .map_err(|error| error.to_string())?; + parse_github_skills_directory(&value, &format!("https://github.com/{owner}/{repository}")) + .map(Some) +} + +pub fn install_extra_skill( + profile_dir: &Path, + source: &ExtraSkillSource, + skill: &ExtraSkill, +) -> io::Result<()> { + validate_source_skill(skill)?; + let (owner, repository) = github_repository_parts(&source.repository) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let revision = if source.revision.is_empty() { + "HEAD" + } else { + validate_revision(&source.revision)?; + source.revision.as_str() + }; + let url = format!( + "https://raw.githubusercontent.com/{owner}/{repository}/{revision}/{}", + skill.path + ); + let bytes = download_skill_package(&url)?; + if let Some(expected) = skill.sha256.as_deref() { + verify_sha256(&bytes, expected)?; + } + let content = String::from_utf8(bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "SKILL.md is not UTF-8"))?; + if !content.trim_start().starts_with("---") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "downloaded SKILL.md is missing frontmatter", + )); + } + let marketplace_skill = MarketplaceSkill { + id: skill.id.clone(), + name: skill.id.clone(), + description: format!("Skill from {}", source.name), + content, + package_url: None, + package_sha256: None, + package_type: None, + tags: Vec::new(), + enabled: true, + updated_at: String::new(), + }; + install_marketplace_skill(profile_dir, &marketplace_skill) +} + +pub fn parse_extra_skill_index(value: &Value) -> Result { + let name = read_string(value, "name").ok_or_else(|| "source index missing name".to_string())?; + let repository = read_string(value, "repository") + .ok_or_else(|| "source index missing repository".to_string())?; + github_repository_parts(&repository)?; + let revision = read_string(value, "revision").unwrap_or_default(); + if !revision.is_empty() { + validate_revision(&revision).map_err(|error| error.to_string())?; + } + let skill_values = value + .get("skills") + .and_then(Value::as_array) + .ok_or_else(|| "source index missing skills".to_string())?; + let mut skills = Vec::with_capacity(skill_values.len()); + for item in skill_values { + let id = read_string(item, "id").ok_or_else(|| "source skill missing id".to_string())?; + let path = + read_string(item, "path").ok_or_else(|| format!("source skill {id} missing path"))?; + let skill = ExtraSkill { + id, + path, + sha256: read_string(item, "sha256"), + }; + validate_source_skill(&skill).map_err(|error| error.to_string())?; + if skills + .iter() + .any(|existing: &ExtraSkill| existing.id == skill.id) + { + return Err(format!("duplicate source skill id: {}", skill.id)); + } + skills.push(skill); + } + let excluded = value + .get("excluded") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|item| { + let id = + read_string(item, "id").ok_or_else(|| "excluded skill missing id".to_string())?; + validate_skill_id(&id).map_err(|error| error.to_string())?; + let reason = read_string(item, "reason") + .ok_or_else(|| format!("excluded skill {id} missing reason"))?; + Ok(ExcludedSkill { id, reason }) + }) + .collect::, String>>()?; + if excluded + .iter() + .any(|item| skills.iter().any(|skill| skill.id == item.id)) + { + return Err("a source skill cannot be both available and excluded".to_string()); + } + Ok(ExtraSkillSource { + name, + repository: normalize_repository_url(&repository), + revision, + skills, + excluded, + }) +} + +pub fn parse_github_skills_directory( + value: &Value, + repository: &str, +) -> Result { + github_repository_parts(repository)?; + let entries = value + .as_array() + .ok_or_else(|| "GitHub skills directory response is not an array".to_string())?; + let mut skills = Vec::new(); + for entry in entries { + if entry.get("type").and_then(Value::as_str) != Some("dir") { + continue; + } + let Some(id) = entry.get("name").and_then(Value::as_str) else { + continue; + }; + validate_skill_id(id).map_err(|error| error.to_string())?; + skills.push(ExtraSkill { + id: id.to_string(), + path: format!("skills/{id}/SKILL.md"), + sha256: None, + }); + } + skills.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(ExtraSkillSource { + name: github_repository_parts(repository)?.1, + repository: normalize_repository_url(repository), + revision: String::new(), + skills, + excluded: Vec::new(), + }) +} + pub fn install_marketplace_skill(profile_dir: &Path, skill: &MarketplaceSkill) -> io::Result<()> { let dir = profile_dir.join("skills").join(safe_skill_dir(&skill.id)); if let Some(url) = skill @@ -623,6 +812,81 @@ fn normalized_content(skill: &MarketplaceSkill) -> String { } } +fn validate_source_skill(skill: &ExtraSkill) -> io::Result<()> { + validate_skill_id(&skill.id)?; + let expected = format!("skills/{}/SKILL.md", skill.id); + if skill.path != expected + || safe_archive_path(&skill.path).as_deref() != Some(Path::new(&expected)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsafe source path for skill {}: {}", skill.id, skill.path), + )); + } + if let Some(hash) = skill.sha256.as_deref() { + if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid sha256 for source skill {}", skill.id), + )); + } + } + Ok(()) +} + +fn validate_skill_id(id: &str) -> io::Result<()> { + if id.is_empty() + || id.starts_with('-') + || id.ends_with('-') + || !id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsafe source skill id: {id}"), + )); + } + Ok(()) +} + +fn validate_revision(revision: &str) -> io::Result<&str> { + if revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(revision) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "source revision must be a 40-character Git commit SHA", + )) + } +} + +fn normalize_repository_url(url: &str) -> String { + url.trim() + .trim_end_matches('/') + .trim_end_matches(".git") + .to_string() +} + +fn github_repository_parts(url: &str) -> Result<(String, String), String> { + let normalized = normalize_repository_url(url); + let path = normalized + .strip_prefix("https://github.com/") + .ok_or_else(|| "extra_skills_source must be an HTTPS GitHub repository URL".to_string())?; + let parts = path.split('/').collect::>(); + if parts.len() != 2 + || parts.iter().any(|part| { + part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }) + { + return Err("extra_skills_source must name one GitHub owner/repository".to_string()); + } + Ok((parts[0].to_string(), parts[1].to_string())) +} + fn safe_skill_dir(id: &str) -> String { let mut output = String::new(); let mut previous_dash = false; @@ -676,6 +940,72 @@ mod tests { assert_eq!(marketplace.default_skill_ids, vec!["review", "off"]); } + #[test] + fn parses_bundled_anthropic_index_and_excludes_document_skills() { + let value = serde_json::from_str(ANTHROPIC_SKILLS_INDEX).unwrap(); + let source = parse_extra_skill_index(&value).unwrap(); + + assert_eq!(source.name, "anthropic"); + assert_eq!(source.repository, ANTHROPIC_SKILLS_URL); + assert_eq!(source.revision.len(), 40); + assert!(source.skills.iter().any(|skill| skill.id == "mcp-builder")); + for id in ["docx", "pdf", "pptx", "xlsx"] { + assert!(!source.skills.iter().any(|skill| skill.id == id)); + assert!(source.excluded.iter().any(|skill| skill.id == id)); + } + } + + #[test] + fn source_index_rejects_unsafe_ids_paths_and_hashes() { + let base = json!({ + "name": "test", + "repository": "https://github.com/example/skills", + "revision": "0123456789abcdef0123456789abcdef01234567", + "skills": [{ "id": "safe-skill", "path": "skills/safe-skill/SKILL.md" }] + }); + assert!(parse_extra_skill_index(&base).is_ok()); + + for (id, path) in [ + ("../escape", "skills/../escape/SKILL.md"), + ("safe-skill", "../SKILL.md"), + ("safe-skill", "skills/other/SKILL.md"), + ("UPPER", "skills/UPPER/SKILL.md"), + ] { + let mut unsafe_index = base.clone(); + unsafe_index["skills"][0]["id"] = json!(id); + unsafe_index["skills"][0]["path"] = json!(path); + assert!( + parse_extra_skill_index(&unsafe_index).is_err(), + "{id}: {path}" + ); + } + + let mut bad_hash = base; + bad_hash["skills"][0]["sha256"] = json!("not-a-sha"); + assert!(parse_extra_skill_index(&bad_hash).is_err()); + } + + #[test] + fn parses_github_directory_entries_without_accepting_unsafe_names() { + let source = parse_github_skills_directory( + &json!([ + { "name": "review", "type": "dir" }, + { "name": "README.md", "type": "file" } + ]), + "https://github.com/example/skills", + ) + .unwrap(); + assert_eq!(source.name, "skills"); + assert_eq!(source.skills.len(), 1); + assert_eq!(source.skills[0].path, "skills/review/SKILL.md"); + + assert!(parse_github_skills_directory( + &json!([{ "name": "../escape", "type": "dir" }]), + "https://github.com/example/skills", + ) + .is_err()); + } + #[test] fn installs_skill_file() { let root = test_dir("jucode-marketplace-skill-test"); diff --git a/docs/skills.md b/docs/skills.md index 66e854a..376efd4 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -4,7 +4,8 @@ JuCode loads skills from these sources: 1. installed user skills under `~/.jucode/skills`; 2. project skills under `/.jucode/skills`, only after the project is trusted; -3. the JuCode marketplace returned by `/v1/skills/marketplace`. +3. the JuCode marketplace returned by `/v1/skills/marketplace`; +4. one optional extra GitHub source configured in `~/.jucode/config.json`. Each skill is a directory containing `SKILL.md`. Frontmatter `name` and `description` fields are used for discovery. A skill named `Code Review` is available as `/code-review`; text after @@ -33,3 +34,29 @@ content at 100 MiB and 4,096 files. Zip and tar.gz packages reject absolute path traversal, links, and special files. File permissions are preserved. Extraction happens in a sibling temporary directory and the completed skill is renamed into place, so a failed update leaves the previous install intact. + +## Extra GitHub source + +Set `extra_skills_source` to the built-in name `anthropic` or to an HTTPS GitHub repository: + +```json +{ + "extra_skills_source": "anthropic" +} +``` + +The built-in source points to . Its small vendored index is +pinned to a reviewed Git commit so listing works without a live GitHub directory request. A custom +repository URL is expected to contain skills at `skills//SKILL.md`; JuCode reads its directory +through the public GitHub API. `/skills list` groups available skills by source, and +`/skills install ` or `/skills update ` downloads the selected `SKILL.md`. Source IDs +and paths are validated before any write. + +Anthropic's `docx`, `pdf`, `pptx`, and `xlsx` skills are source-available rather than Apache-2.0. +JuCode lists them as not offered and will not install them from the built-in source. + +To refresh the built-in index, obtain the current `main` commit with +`git ls-remote https://github.com/anthropics/skills refs/heads/main`, review the upstream +`skills/` directory and each skill's license, then update the revision and reviewed entries in +`crates/agent-core/src/anthropic-skills-index.json`. Keep source-available entries under +`excluded`, and run the agent-core tests after editing.