From 55169c7b79cd1a49fe0f80889863a8e0c51c0b7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 03:00:37 +0000 Subject: [PATCH 1/3] Add Anthropic skills marketplace source Co-authored-by: Gao Yu --- README.md | 2 +- docs/skills.md | 48 ++ scripts/refresh-anthropic-skills.mjs | 76 ++ src-tauri/Cargo.lock | 151 +++- src-tauri/Cargo.toml | 4 + src-tauri/resources/anthropic-skills.json | 198 +++++ src-tauri/src/lib.rs | 35 +- src-tauri/src/skills.rs | 928 ++++++++++++++++++++++ src/lib/Marketplace.svelte | 159 +++- src/lib/i18n/messages/settings.ts | 22 +- src/lib/protocol.ts | 31 +- src/routes/+page.svelte | 2 +- 12 files changed, 1580 insertions(+), 76 deletions(-) create mode 100644 docs/skills.md create mode 100644 scripts/refresh-anthropic-skills.mjs create mode 100644 src-tauri/resources/anthropic-skills.json create mode 100644 src-tauri/src/skills.rs diff --git a/README.md b/README.md index b9d6713..a228a10 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ WebView (Svelte) ──invoke('send_op', …)──▶ src-tauri (Rust) ─ - **Setup wizard** — first-run environment check (git + engine), guided/auto install, JuCode OAuth login or API-key path; **logout** per provider in settings. - **Branch tree** (`/tree`), **resume** (`/resume`), **model picker** (`/model`), - context/cost ring, skills marketplace. + context/cost ring, combined [JuCode + Anthropic skills marketplace](docs/skills.md). - **Theming** — system / light / dark; image paste & drag-drop; desktop notifications. Keyboard: `⌘K` palette · `⌘F` find · `⌘N` new session · `⌘B` toggle panel · `⌘,` settings. diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..8055290 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,48 @@ +# Skills marketplace + +JuCode Desktop combines two upstream sources in one marketplace: + +- the official JuCode marketplace at `/v1/skills/marketplace`; +- [`anthropics/skills`](https://github.com/anthropics/skills), fetched directly from GitHub. + +Desktop does not mirror or self-host either source. The Anthropic listing uses the small public +metadata snapshot in `src-tauri/resources/anthropic-skills.json` so GitHub API availability and +anonymous rate limits cannot make the source disappear. Installing one of those skills fetches its +current files directly from `raw.githubusercontent.com` after reading the repository tree from the +GitHub API. + +Use `node scripts/refresh-anthropic-skills.mjs` to refresh the snapshot. The script discovers public +skill directories through the GitHub Contents API, reads each `SKILL.md` frontmatter, checks its +`LICENSE.txt`, and preserves curated tags already in the snapshot. Review the diff, especially +license classification, before committing an update. + +## Install location and safety + +The active session backend selects the personal install directory: + +- JuCode and Codex sessions: `~/.jucode/skills/`; +- Claude Code sessions: `~/.claude/skills/`. + +Skill IDs and every downloaded relative path are validated before joining them to that directory. +GitHub tree links, submodules, absolute paths, and parent traversal are rejected. Downloads are +limited to 20 MiB per file, 100 MiB total, and 4,096 files. JuCode zip and tar.gz packages retain +their existing 20 MiB compressed limit, 100 MiB extracted limit, and the same traversal/link/file +count checks. Installation stages files next to the destination and atomically replaces the old +version only after a valid `SKILL.md` exists. + +Skills are executable instructions and may include scripts. Treat installation like installing +software and review upstream content before using it with sensitive projects. + +## Anthropic document-skill licensing + +Most examples in `anthropics/skills` are Apache-2.0. The `docx`, `pdf`, `pptx`, and `xlsx` folders +are different: Anthropic describes them as source-available rather than open source, and their +terms prohibit redistribution. JuCode does not bundle or redistribute those files; an explicit +install downloads the selected folder from Anthropic's GitHub repository to the user's machine. +Users remain responsible for the upstream terms. + +Anthropic's preset document skills for Word, PDF, PowerPoint, and Excel are available in supported +Claude API/hosted surfaces, but they are not preset skills in Claude Code. Claude Code supports +filesystem-based custom skills instead. A repository copy installed into `~/.claude/skills` is a +custom skill and does not become, or carry the runtime guarantees of, Anthropic's preset hosted +skill. diff --git a/scripts/refresh-anthropic-skills.mjs b/scripts/refresh-anthropic-skills.mjs new file mode 100644 index 0000000..eb341bb --- /dev/null +++ b/scripts/refresh-anthropic-skills.mjs @@ -0,0 +1,76 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +const output = new URL('../src-tauri/resources/anthropic-skills.json', import.meta.url); +const repository = 'https://github.com/anthropics/skills'; +const api = 'https://api.github.com/repos/anthropics/skills/contents/skills?ref=main'; +const headers = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'JuCode-Desktop-index-refresh' +}; + +async function get(url, optional = false) { + const response = await fetch(url, { headers }); + if (optional && response.status === 404) return ''; + if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`); + return response.text(); +} + +function scalar(value) { + const trimmed = value.trim(); + if (trimmed.startsWith('"')) { + try { + return JSON.parse(trimmed); + } catch { + // Keep the source text when an upstream scalar is not JSON-compatible. + } + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1); + return trimmed; +} + +function frontmatter(markdown, key) { + const block = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; + const value = block.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1] ?? ''; + return scalar(value); +} + +const previous = JSON.parse(await readFile(output, 'utf8')); +const known = new Map(previous.skills.map((skill) => [skill.id, skill])); +const directories = JSON.parse(await get(api)) + .filter((entry) => entry.type === 'dir') + .sort((left, right) => left.name.localeCompare(right.name)); + +const skills = []; +for (const directory of directories) { + const id = directory.name; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { + throw new Error(`Refusing unsafe skill id: ${id}`); + } + const rawBase = `https://raw.githubusercontent.com/anthropics/skills/main/skills/${id}`; + const markdown = await get(`${rawBase}/SKILL.md`); + const licenseText = await get(`${rawBase}/LICENSE.txt`, true); + const redistributable = + !licenseText.includes('All rights reserved') && + !licenseText.includes('ADDITIONAL RESTRICTIONS'); + const old = known.get(id); + skills.push({ + id, + name: old?.name ?? frontmatter(markdown, 'name') ?? id, + description: frontmatter(markdown, 'description') || old?.description || `Anthropic ${id} skill`, + tags: old?.tags ?? [], + skill_url: `${rawBase}/SKILL.md`, + homepage: `${repository}/tree/main/skills/${id}`, + license: redistributable ? 'Apache-2.0' : 'Anthropic source-available', + redistributable + }); +} + +const index = { + schema_version: 1, + repository, + ref: 'main', + tree_url: 'https://api.github.com/repos/anthropics/skills/git/trees/main?recursive=1', + skills +}; +await writeFile(output, `${JSON.stringify(index, null, 2)}\n`); +console.log(`Updated ${skills.length} Anthropic skills in ${output.pathname}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d72d29f..ec6fb67 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -283,6 +283,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -489,7 +498,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -523,7 +532,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -547,6 +556,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -626,6 +641,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -667,6 +691,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -798,10 +831,13 @@ version = "0.2.1" dependencies = [ "base64 0.22.1", "chacha20poly1305", + "flate2", "getrandom 0.2.17", "portable-pty", "serde", "serde_json", + "sha2 0.11.0", + "tar", "tauri", "tauri-build", "tauri-plugin-deep-link", @@ -814,6 +850,7 @@ dependencies = [ "tauri-plugin-window-state", "ureq", "window-vibrancy 0.5.3", + "zip 8.6.0", ] [[package]] @@ -822,8 +859,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -1123,12 +1171,13 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1651,6 +1700,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -2236,6 +2294,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -2905,7 +2973,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2918,7 +2986,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2941,7 +3009,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3740,8 +3808,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -4139,7 +4218,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.118", "tauri-utils", "thiserror 2.0.18", @@ -4340,7 +4419,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -4867,6 +4946,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -4949,7 +5034,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -5833,7 +5918,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", @@ -6054,12 +6139,44 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2665d31..47ce37a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,10 @@ ureq = { version = "2", features = ["json"] } # default-features 关掉以免多带一份 std/stream 支持。 chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc", "getrandom"] } getrandom = "0.2" +flate2 = "1.1.10" +tar = "0.4.46" +sha2 = "0.11.0" +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } # 仅桌面端的插件(updater / 单实例 / 进程重启) [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] diff --git a/src-tauri/resources/anthropic-skills.json b/src-tauri/resources/anthropic-skills.json new file mode 100644 index 0000000..6c38187 --- /dev/null +++ b/src-tauri/resources/anthropic-skills.json @@ -0,0 +1,198 @@ +{ + "schema_version": 1, + "repository": "https://github.com/anthropics/skills", + "ref": "main", + "tree_url": "https://api.github.com/repos/anthropics/skills/git/trees/main?recursive=1", + "skills": [ + { + "id": "academy-guide", + "name": "Academy Guide", + "description": "Recommends relevant Claude Academy courses, tutorials, and use cases.", + "tags": ["education", "guidance"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/academy-guide/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/academy-guide", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "algorithmic-art", + "name": "Algorithmic Art", + "description": "Creates generative art with deterministic randomness and interactive parameters.", + "tags": ["creative", "design"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/algorithmic-art/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/algorithmic-art", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "brand-guidelines", + "name": "Brand Guidelines", + "description": "Applies Anthropic brand colors, typography, and visual identity to artifacts.", + "tags": ["creative", "brand"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/brand-guidelines/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/brand-guidelines", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "canvas-design", + "name": "Canvas Design", + "description": "Creates polished static visual designs in PNG and PDF formats.", + "tags": ["creative", "design"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/canvas-design/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/canvas-design", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "claude-api", + "name": "Claude API", + "description": "Provides current Claude API and SDK guidance for building applications.", + "tags": ["development", "api"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/claude-api/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/claude-api", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "discernment-nudge", + "name": "Discernment Nudge", + "description": "Adds concise follow-up questions that help check facts and probe reasoning.", + "tags": ["communication", "reasoning"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/discernment-nudge/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/discernment-nudge", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "doc-coauthoring", + "name": "Document Coauthoring", + "description": "Guides a structured workflow for collaboratively drafting documentation.", + "tags": ["documents", "communication"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/doc-coauthoring/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/doc-coauthoring", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "docx", + "name": "Word Documents", + "description": "Creates, reads, edits, and validates Word documents and templates.", + "tags": ["documents", "source-available"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/docx/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/docx", + "license": "Anthropic source-available", + "redistributable": false + }, + { + "id": "frontend-design", + "name": "Frontend Design", + "description": "Guides distinctive, intentional visual design for web interfaces.", + "tags": ["development", "design"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/frontend-design", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "internal-comms", + "name": "Internal Communications", + "description": "Provides patterns for status updates, newsletters, FAQs, and internal writing.", + "tags": ["enterprise", "communication"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/internal-comms/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/internal-comms", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "mcp-builder", + "name": "MCP Builder", + "description": "Guides creation of reliable Model Context Protocol servers.", + "tags": ["development", "mcp"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/mcp-builder/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/mcp-builder", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "pdf", + "name": "PDF Documents", + "description": "Creates, reads, edits, combines, and validates PDF documents.", + "tags": ["documents", "source-available"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/pdf/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/pdf", + "license": "Anthropic source-available", + "redistributable": false + }, + { + "id": "pptx", + "name": "PowerPoint Presentations", + "description": "Creates, edits, renders, and validates PowerPoint presentations.", + "tags": ["documents", "source-available"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/pptx/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/pptx", + "license": "Anthropic source-available", + "redistributable": false + }, + { + "id": "skill-creator", + "name": "Skill Creator", + "description": "Guides creation and improvement of reusable Agent Skills.", + "tags": ["development", "skills"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/skill-creator/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/skill-creator", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "slack-gif-creator", + "name": "Slack GIF Creator", + "description": "Creates compact animated GIFs optimized for Slack.", + "tags": ["creative", "communication"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/slack-gif-creator/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/slack-gif-creator", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "theme-factory", + "name": "Theme Factory", + "description": "Applies cohesive color and typography themes to generated artifacts.", + "tags": ["creative", "design"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/theme-factory/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/theme-factory", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "web-artifacts-builder", + "name": "Web Artifacts Builder", + "description": "Builds complex interactive web artifacts with modern frontend tooling.", + "tags": ["development", "web"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/web-artifacts-builder/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/web-artifacts-builder", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "webapp-testing", + "name": "Web App Testing", + "description": "Tests local web applications with browser automation and screenshots.", + "tags": ["development", "testing"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/webapp-testing/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/webapp-testing", + "license": "Apache-2.0", + "redistributable": true + }, + { + "id": "xlsx", + "name": "Excel Spreadsheets", + "description": "Creates, edits, recalculates, and analyzes spreadsheet workbooks.", + "tags": ["documents", "source-available"], + "skill_url": "https://raw.githubusercontent.com/anthropics/skills/main/skills/xlsx/SKILL.md", + "homepage": "https://github.com/anthropics/skills/tree/main/skills/xlsx", + "license": "Anthropic source-available", + "redistributable": false + } + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c5d53e..d9a2a76 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod installer; mod plugins; mod secrets; mod shell_env; +mod skills; use backend::BackendKind; @@ -690,10 +691,9 @@ fn jucode_get(path: &str) -> Result { .map_err(|e| e.to_string()) } -/// Fetches the JuCode skills marketplace. The endpoint is public; the access -/// token (when present) is sent best-effort without forcing a refresh. -#[tauri::command(async)] -fn fetch_marketplace() -> Result { +/// Fetches the JuCode source payload. The endpoint is public; the access token +/// (when present) is sent best-effort without forcing a refresh. +fn fetch_jucode_marketplace() -> Result { let url = format!("{}/v1/skills/marketplace", jucode_api_url()); let key = read_auth() .get("jucode") @@ -710,6 +710,32 @@ fn fetch_marketplace() -> Result { .map_err(|e| e.to_string()) } +/// Combines the official JuCode marketplace with the vendored public metadata +/// index for github.com/anthropics/skills. A JuCode network failure is returned +/// as a source warning so the independently installable Anthropic catalog stays +/// available. +#[tauri::command(async)] +fn fetch_marketplace(backend: String) -> Result { + skills::catalog(fetch_jucode_marketplace(), &backend) +} + +/// Installs directly into the active backend's personal skill directory. The +/// source is re-fetched here instead of trusting package URLs or content sent by +/// the webview. +#[tauri::command(async)] +fn install_marketplace_skill( + source: String, + id: String, + backend: String, +) -> Result { + let jucode = if source == "jucode" { + Some(fetch_jucode_marketplace()?) + } else { + None + }; + skills::install(&source, &id, &backend, jucode.as_ref()) +} + /// Account overview (profile + balance + active plan) for the GUI. #[tauri::command(async)] fn fetch_account_info() -> Result { @@ -2692,6 +2718,7 @@ pub fn run() { set_auth_key, remove_auth_key, fetch_marketplace, + install_marketplace_skill, fetch_account_info, fetch_usage, fetch_usage_logs, diff --git a/src-tauri/src/skills.rs b/src-tauri/src/skills.rs new file mode 100644 index 0000000..bdee795 --- /dev/null +++ b/src-tauri/src/skills.rs @@ -0,0 +1,928 @@ +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{self, Cursor, Read}, + path::{Component, Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tar::Archive; +use zip::ZipArchive; + +const ANTHROPIC_INDEX: &[u8] = include_bytes!("../resources/anthropic-skills.json"); +const MAX_INDEX_BYTES: usize = 4 * 1024 * 1024; +const MAX_PACKAGE_BYTES: usize = 20 * 1024 * 1024; +const MAX_SINGLE_FILE_BYTES: usize = 20 * 1024 * 1024; +const MAX_EXTRACTED_BYTES: u64 = 100 * 1024 * 1024; +const MAX_PACKAGE_FILES: usize = 4096; +const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +#[derive(Debug, Deserialize)] +struct AnthropicIndex { + schema_version: u32, + repository: String, + #[serde(rename = "ref")] + git_ref: String, + tree_url: String, + skills: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct AnthropicSkill { + id: String, + name: String, + description: String, + tags: Vec, + skill_url: String, + homepage: String, + license: String, + redistributable: bool, +} + +#[derive(Debug)] +struct JucodeSkill { + id: String, + name: String, + description: String, + content: String, + package_url: Option, + package_sha256: Option, + package_type: Option, + tags: Vec, + enabled: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogSkill { + id: String, + name: String, + description: String, + tags: Vec, + source: &'static str, + is_default: bool, + installed: bool, + license: String, + redistributable: bool, + homepage: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillCatalog { + skills: Vec, + warnings: Vec, + install_dir: String, +} + +#[derive(Debug, Deserialize)] +struct GithubTree { + #[serde(default)] + truncated: bool, + tree: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubTreeEntry { + path: String, + mode: String, + #[serde(rename = "type")] + kind: String, + size: Option, +} + +pub fn catalog( + jucode_marketplace: Result, + backend: &str, +) -> Result { + let index = parse_anthropic_index(ANTHROPIC_INDEX)?; + let skills_dir = skills_dir(backend).map_err(|error| error.to_string())?; + let mut skills = Vec::new(); + let mut warnings = Vec::new(); + + match jucode_marketplace { + Ok(value) => match parse_jucode_marketplace(&value) { + Ok((jucode, defaults)) => { + skills.extend(jucode.into_iter().map(|skill| { + let installed = installed_at(&skills_dir, &skill.id); + CatalogSkill { + id: skill.id.clone(), + name: skill.name, + description: skill.description, + tags: skill.tags, + source: "jucode", + is_default: defaults.iter().any(|id| id == &skill.id), + installed, + license: String::new(), + redistributable: true, + homepage: String::new(), + } + })); + } + Err(error) => warnings.push(format!("JuCode marketplace: {error}")), + }, + Err(error) => warnings.push(format!("JuCode marketplace: {error}")), + } + + skills.extend(index.skills.into_iter().map(|skill| CatalogSkill { + installed: installed_at(&skills_dir, &skill.id), + id: skill.id, + name: skill.name, + description: skill.description, + tags: skill.tags, + source: "anthropic", + is_default: false, + license: skill.license, + redistributable: skill.redistributable, + homepage: skill.homepage, + })); + skills.sort_by(|left, right| { + left.source + .cmp(right.source) + .then_with(|| left.name.cmp(&right.name)) + }); + + Ok(SkillCatalog { + skills, + warnings, + install_dir: skills_dir.display().to_string(), + }) +} + +pub fn install( + source: &str, + id: &str, + backend: &str, + jucode_marketplace: Option<&Value>, +) -> Result { + let destination = skills_dir(backend) + .map_err(|error| error.to_string())? + .join(validated_skill_id(id)?); + + match source { + "anthropic" => { + let index = parse_anthropic_index(ANTHROPIC_INDEX)?; + let skill = index + .skills + .iter() + .find(|skill| skill.id == id) + .ok_or_else(|| format!("Anthropic skill not found: {id}"))?; + install_anthropic_skill(&index, skill, &destination).map_err(|e| e.to_string())?; + } + "jucode" => { + let marketplace = jucode_marketplace + .ok_or_else(|| "JuCode marketplace is unavailable".to_string())?; + let (skills, _) = parse_jucode_marketplace(marketplace)?; + let skill = skills + .iter() + .find(|skill| skill.id == id) + .ok_or_else(|| format!("JuCode skill not found: {id}"))?; + install_jucode_skill(skill, &destination).map_err(|e| e.to_string())?; + } + other => return Err(format!("unknown skill source: {other}")), + } + + Ok(destination.display().to_string()) +} + +fn parse_anthropic_index(bytes: &[u8]) -> Result { + if bytes.len() > MAX_INDEX_BYTES { + return Err(format!( + "Anthropic skills index exceeds {MAX_INDEX_BYTES} byte limit" + )); + } + let index = + serde_json::from_slice::(bytes).map_err(|error| error.to_string())?; + if index.schema_version != 1 { + return Err(format!( + "unsupported Anthropic skills index schema {}", + index.schema_version + )); + } + if index.repository != "https://github.com/anthropics/skills" { + return Err("unexpected Anthropic skills repository".to_string()); + } + if index.git_ref.trim().is_empty() || index.tree_url.trim().is_empty() { + return Err("Anthropic skills index is missing its GitHub ref or tree URL".to_string()); + } + for skill in &index.skills { + validated_skill_id(&skill.id)?; + if skill.name.trim().is_empty() + || skill.description.trim().is_empty() + || !skill + .skill_url + .starts_with("https://raw.githubusercontent.com/anthropics/skills/") + || !skill + .homepage + .starts_with("https://github.com/anthropics/skills/") + { + return Err(format!( + "invalid Anthropic skills index entry: {}", + skill.id + )); + } + } + Ok(index) +} + +fn parse_jucode_marketplace(value: &Value) -> Result<(Vec, Vec), String> { + let rows = value + .get("skills") + .and_then(Value::as_array) + .ok_or_else(|| "response missing skills".to_string())?; + let skills = rows + .iter() + .filter_map(parse_jucode_skill) + .filter(|skill| skill.enabled) + .collect(); + let defaults = value + .get("default_skill_ids") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .collect(); + Ok((skills, defaults)) +} + +fn parse_jucode_skill(value: &Value) -> Option { + let id = json_string(value, "id")?; + validated_skill_id(&id).ok()?; + let name = json_string(value, "name")?; + let description = json_string(value, "description")?; + let content = json_string(value, "content").unwrap_or_default(); + let package_url = json_string(value, "package_url"); + if content.is_empty() && package_url.is_none() { + return None; + } + Some(JucodeSkill { + id, + name, + description, + content, + package_url, + package_sha256: json_string(value, "package_sha256"), + package_type: json_string(value, "package_type"), + tags: value + .get("tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(str::to_string) + .collect(), + enabled: value + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(true), + }) +} + +fn json_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 install_anthropic_skill( + index: &AnthropicIndex, + skill: &AnthropicSkill, + destination: &Path, +) -> io::Result<()> { + let tree_bytes = http_get_bounded(&index.tree_url, MAX_INDEX_BYTES)?; + let mut tree = serde_json::from_slice::(&tree_bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if tree.truncated { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "GitHub returned a truncated Anthropic skills tree", + )); + } + + let mut files = Vec::new(); + let mut declared_bytes = 0_u64; + for entry in tree.tree.drain(..) { + let Some(relative) = anthropic_relative_path(&entry.path, &skill.id)? else { + continue; + }; + match entry.kind.as_str() { + "tree" => continue, + "blob" if entry.mode == "100644" || entry.mode == "100755" => {} + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported GitHub tree entry: {}", entry.path), + )); + } + } + let size = entry.size.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("GitHub tree entry has no size: {}", entry.path), + ) + })?; + if size > MAX_SINGLE_FILE_BYTES as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("skill file exceeds {MAX_SINGLE_FILE_BYTES} byte limit"), + )); + } + declared_bytes = declared_bytes.saturating_add(size); + if declared_bytes > MAX_EXTRACTED_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("skill exceeds {MAX_EXTRACTED_BYTES} byte limit"), + )); + } + files.push((entry, relative)); + if files.len() > MAX_PACKAGE_FILES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("skill exceeds {MAX_PACKAGE_FILES} file limit"), + )); + } + } + if files.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Anthropic skill files not found: {}", skill.id), + )); + } + files.sort_by(|left, right| left.0.path.cmp(&right.0.path)); + + let staging = staging_dir(destination, "download"); + recreate_dir(&staging)?; + let result = (|| { + let mut actual_bytes = 0_u64; + for (entry, relative) in files { + let url = format!( + "https://raw.githubusercontent.com/anthropics/skills/{}/{}", + encode_url_path(&index.git_ref), + encode_url_path(&entry.path) + ); + let bytes = http_get_bounded(&url, MAX_SINGLE_FILE_BYTES)?; + actual_bytes = actual_bytes.saturating_add(bytes.len() as u64); + if actual_bytes > MAX_EXTRACTED_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("skill exceeds {MAX_EXTRACTED_BYTES} byte limit"), + )); + } + let path = staging.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, bytes)?; + apply_download_permissions(&path, entry.mode == "100755")?; + } + if !staging.join("SKILL.md").is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Anthropic skill does not contain SKILL.md", + )); + } + atomic_replace_dir(&staging, destination) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&staging); + } + result +} + +fn anthropic_relative_path(path: &str, skill_id: &str) -> io::Result> { + let safe = safe_path_components(Path::new(path)).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("unsafe path in GitHub tree: {path}"), + ) + })?; + let components = safe.components().collect::>(); + if components.len() < 2 + || components[0].as_os_str() != "skills" + || components[1].as_os_str() != skill_id + { + return Ok(None); + } + if components.len() == 2 { + return Ok(None); + } + let mut relative = PathBuf::new(); + for component in &components[2..] { + relative.push(component.as_os_str()); + } + Ok(Some(relative)) +} + +fn install_jucode_skill(skill: &JucodeSkill, destination: &Path) -> io::Result<()> { + if let Some(url) = skill.package_url.as_deref() { + install_jucode_package(skill, destination, url) + } else { + let content = normalized_content(skill); + if content.len() > MAX_SINGLE_FILE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("skill content exceeds {MAX_SINGLE_FILE_BYTES} byte limit"), + )); + } + let staging = staging_dir(destination, "inline"); + recreate_dir(&staging)?; + if let Err(error) = fs::write(staging.join("SKILL.md"), content) { + let _ = fs::remove_dir_all(&staging); + return Err(error); + } + atomic_replace_dir(&staging, destination) + } +} + +fn install_jucode_package(skill: &JucodeSkill, destination: &Path, url: &str) -> io::Result<()> { + let expected = skill.package_sha256.as_deref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "marketplace package is missing required package_sha256", + ) + })?; + let bytes = download_package(url)?; + verify_sha256(&bytes, expected)?; + let extract_dir = staging_dir(destination, "extract"); + recreate_dir(&extract_dir)?; + let package_type = skill + .package_type + .as_deref() + .filter(|kind| !kind.trim().is_empty()) + .unwrap_or_else(|| infer_package_type(url)); + let extracted = match package_type { + "zip" => extract_zip(&bytes, &extract_dir), + "tar.gz" | "tgz" => extract_tar_gz(&bytes, &extract_dir), + other => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsupported skill package type: {other}"), + )), + }; + if let Err(error) = extracted { + let _ = fs::remove_dir_all(&extract_dir); + return Err(error); + } + let root = find_skill_root(&extract_dir).ok_or_else(|| { + let _ = fs::remove_dir_all(&extract_dir); + io::Error::new( + io::ErrorKind::InvalidData, + "skill package does not contain SKILL.md", + ) + })?; + if root == extract_dir { + return atomic_replace_dir(&extract_dir, destination); + } + + let ready = staging_dir(destination, "ready"); + recreate_dir(&ready)?; + if let Err(error) = copy_dir_contents(&root, &ready) { + let _ = fs::remove_dir_all(&extract_dir); + let _ = fs::remove_dir_all(&ready); + return Err(error); + } + let _ = fs::remove_dir_all(&extract_dir); + atomic_replace_dir(&ready, destination) +} + +fn download_package(url: &str) -> io::Result> { + if let Some(path) = url.strip_prefix("file://") { + return read_bounded(fs::File::open(path)?, MAX_PACKAGE_BYTES); + } + if !url.contains("://") { + return read_bounded(fs::File::open(url)?, MAX_PACKAGE_BYTES); + } + http_get_bounded(url, MAX_PACKAGE_BYTES) +} + +fn http_get_bounded(url: &str, limit: usize) -> io::Result> { + let response = ureq::get(url) + .timeout(HTTP_TIMEOUT) + .set("Accept", "application/vnd.github+json") + .set("User-Agent", "JuCode-Desktop") + .call() + .map_err(|error| io::Error::other(error.to_string()))?; + read_bounded(response.into_reader(), limit) +} + +fn read_bounded(reader: impl Read, limit: usize) -> io::Result> { + let mut reader = reader.take((limit + 1) as u64); + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("download exceeds {limit} byte limit"), + )); + } + Ok(bytes) +} + +fn verify_sha256(bytes: &[u8], expected: &str) -> io::Result<()> { + let actual = format!("{:x}", Sha256::digest(bytes)); + if actual.eq_ignore_ascii_case(expected.trim()) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "skill package sha256 mismatch: expected {}, got {actual}", + expected.trim() + ), + )) + } +} + +fn extract_zip(bytes: &[u8], destination: &Path) -> io::Result<()> { + let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(zip_error)?; + if archive.len() > MAX_PACKAGE_FILES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("skill package exceeds {MAX_PACKAGE_FILES} file limit"), + )); + } + let mut extracted_bytes = 0_u64; + for index in 0..archive.len() { + let mut file = archive.by_index(index).map_err(zip_error)?; + if let Some(mode) = file.unix_mode() { + let file_type = mode & 0o170000; + if file_type != 0 && file_type != 0o100000 && file_type != 0o040000 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "skill package links and special files are not allowed", + )); + } + } + extracted_bytes = extracted_bytes.saturating_add(file.size()); + if extracted_bytes > MAX_EXTRACTED_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("extracted skill exceeds {MAX_EXTRACTED_BYTES} byte limit"), + )); + } + let relative = safe_path_components(Path::new(file.name())).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("unsafe path in skill package: {}", file.name()), + ) + })?; + let path = destination.join(relative); + if file.is_dir() { + fs::create_dir_all(&path)?; + continue; + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut output = fs::File::create(&path)?; + io::copy(&mut file, &mut output)?; + apply_zip_permissions(file.unix_mode(), &path)?; + } + Ok(()) +} + +fn extract_tar_gz(bytes: &[u8], destination: &Path) -> io::Result<()> { + let decoder = GzDecoder::new(Cursor::new(bytes)); + let mut archive = Archive::new(decoder); + let mut extracted_bytes = 0_u64; + for (index, entry) in archive.entries()?.enumerate() { + if index >= MAX_PACKAGE_FILES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("skill package exceeds {MAX_PACKAGE_FILES} file limit"), + )); + } + let mut entry = entry?; + let source = entry.path()?; + let relative = safe_path_components(&source).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("unsafe path in skill package: {}", source.display()), + ) + })?; + let kind = entry.header().entry_type(); + if !kind.is_file() && !kind.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "skill package links and special files are not allowed", + )); + } + extracted_bytes = extracted_bytes.saturating_add(entry.header().size()?); + if extracted_bytes > MAX_EXTRACTED_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("extracted skill exceeds {MAX_EXTRACTED_BYTES} byte limit"), + )); + } + let path = destination.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + entry.unpack(path)?; + } + Ok(()) +} + +fn safe_path_components(path: &Path) -> Option { + let mut safe = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => safe.push(part), + Component::CurDir => {} + _ => return None, + } + } + (!safe.as_os_str().is_empty()).then_some(safe) +} + +fn find_skill_root(directory: &Path) -> Option { + if directory.join("SKILL.md").is_file() { + return Some(directory.to_path_buf()); + } + for entry in fs::read_dir(directory).ok()? { + let path = entry.ok()?.path(); + if path.is_dir() { + if let Some(found) = find_skill_root(&path) { + return Some(found); + } + } + } + None +} + +fn copy_dir_contents(source: &Path, destination: &Path) -> io::Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_contents(&source_path, &destination_path)?; + } else { + fs::copy(&source_path, &destination_path)?; + fs::set_permissions(&destination_path, fs::metadata(&source_path)?.permissions())?; + } + } + Ok(()) +} + +fn recreate_dir(directory: &Path) -> io::Result<()> { + if directory.exists() { + fs::remove_dir_all(directory)?; + } + fs::create_dir_all(directory) +} + +fn atomic_replace_dir(staging: &Path, destination: &Path) -> io::Result<()> { + let parent = destination + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "skill has no parent"))?; + if staging.parent() != Some(parent) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "skill staging path escapes skills directory", + )); + } + fs::create_dir_all(parent)?; + let backup = staging_dir(destination, "backup"); + let had_destination = destination.exists(); + if had_destination { + fs::rename(destination, &backup)?; + } + if let Err(error) = fs::rename(staging, destination) { + if had_destination { + let _ = fs::rename(&backup, destination); + } + let _ = fs::remove_dir_all(staging); + return Err(error); + } + if had_destination { + let _ = fs::remove_dir_all(backup); + } + Ok(()) +} + +fn staging_dir(destination: &Path, label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let name = destination + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("skill"); + destination.with_file_name(format!(".{name}-{label}-{nonce}")) +} + +fn skills_dir(backend: &str) -> io::Result { + let home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))?; + Ok(skills_dir_for_home(Path::new(&home), backend)) +} + +fn skills_dir_for_home(home: &Path, backend: &str) -> PathBuf { + if backend == "claude" { + home.join(".claude").join("skills") + } else { + home.join(".jucode").join("skills") + } +} + +fn installed_at(skills_dir: &Path, id: &str) -> bool { + validated_skill_id(id) + .map(|id| skills_dir.join(id).join("SKILL.md").is_file()) + .unwrap_or(false) +} + +fn validated_skill_id(id: &str) -> Result<&str, String> { + let id = id.trim(); + if id.is_empty() + || id.len() > 128 + || !id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || id.starts_with('-') + || id.ends_with('-') + { + return Err(format!("invalid skill id: {id}")); + } + Ok(id) +} + +fn normalized_content(skill: &JucodeSkill) -> String { + let content = skill.content.trim_end(); + if content.starts_with("---") { + format!("{content}\n") + } else { + format!( + "---\nname: {}\ndescription: {}\n---\n\n{content}\n", + skill.name, skill.description + ) + } +} + +fn infer_package_type(url: &str) -> &str { + let lowercase = url.to_ascii_lowercase(); + if lowercase.ends_with(".tar.gz") || lowercase.ends_with(".tgz") { + "tar.gz" + } else { + "zip" + } +} + +fn encode_url_path(path: &str) -> String { + let mut output = String::with_capacity(path.len()); + for byte in path.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') { + output.push(byte as char); + } else { + output.push_str(&format!("%{byte:02X}")); + } + } + output +} + +#[cfg(unix)] +fn apply_download_permissions(path: &Path, executable: bool) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + path, + fs::Permissions::from_mode(if executable { 0o755 } else { 0o644 }), + ) +} + +#[cfg(not(unix))] +fn apply_download_permissions(_path: &Path, _executable: bool) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn apply_zip_permissions(mode: Option, path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = mode { + fs::set_permissions(path, fs::Permissions::from_mode(mode))?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn apply_zip_permissions(_mode: Option, _path: &Path) -> io::Result<()> { + Ok(()) +} + +fn zip_error(error: zip::result::ZipError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_vendored_anthropic_index() { + let index = parse_anthropic_index(ANTHROPIC_INDEX).unwrap(); + assert_eq!(index.repository, "https://github.com/anthropics/skills"); + assert!(index + .skills + .iter() + .any(|skill| skill.id == "frontend-design")); + let restricted = index + .skills + .iter() + .filter(|skill| !skill.redistributable) + .map(|skill| skill.id.as_str()) + .collect::>(); + assert_eq!(restricted, ["docx", "pdf", "pptx", "xlsx"]); + } + + #[test] + fn backend_selects_confined_personal_install_path() { + let home = Path::new("/home/tester"); + assert_eq!( + skills_dir_for_home(home, "jucode").join("review"), + Path::new("/home/tester/.jucode/skills/review") + ); + assert_eq!( + skills_dir_for_home(home, "claude").join("review"), + Path::new("/home/tester/.claude/skills/review") + ); + assert!(validated_skill_id("../escape").is_err()); + assert!(validated_skill_id("nested/escape").is_err()); + } + + #[test] + fn github_tree_paths_cannot_escape_selected_skill() { + assert_eq!( + anthropic_relative_path("skills/frontend-design/SKILL.md", "frontend-design").unwrap(), + Some(PathBuf::from("SKILL.md")) + ); + assert_eq!( + anthropic_relative_path("skills/pdf/SKILL.md", "frontend-design").unwrap(), + None + ); + assert!( + anthropic_relative_path("skills/frontend-design/../../escape", "frontend-design") + .is_err() + ); + assert!(anthropic_relative_path("/tmp/escape", "frontend-design").is_err()); + } + + #[test] + fn catalog_combines_sources_and_marks_target_install() { + let root = test_dir("jucode-desktop-skill-catalog"); + let skills = skills_dir_for_home(&root, "claude"); + fs::create_dir_all(skills.join("frontend-design")).unwrap(); + fs::write( + skills.join("frontend-design/SKILL.md"), + "---\nname: frontend-design\ndescription: test\n---\n", + ) + .unwrap(); + let jucode = json!({ + "skills": [{ + "id": "review", + "name": "Review", + "description": "Review code", + "content": "Review carefully.", + "tags": ["code"] + }], + "default_skill_ids": ["review"] + }); + let index = parse_anthropic_index(ANTHROPIC_INDEX).unwrap(); + let (market, defaults) = parse_jucode_marketplace(&jucode).unwrap(); + assert_eq!(market.len(), 1); + assert_eq!(defaults, ["review"]); + assert!(installed_at(&skills, &index.skills[8].id)); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn package_download_has_a_hard_size_limit() { + let error = read_bounded( + Cursor::new(vec![0_u8; MAX_PACKAGE_BYTES + 1]), + MAX_PACKAGE_BYTES, + ) + .unwrap_err(); + assert!(error.to_string().contains("byte limit")); + } + + fn test_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } +} diff --git a/src/lib/Marketplace.svelte b/src/lib/Marketplace.svelte index 39e1070..7fbae36 100644 --- a/src/lib/Marketplace.svelte +++ b/src/lib/Marketplace.svelte @@ -1,21 +1,24 @@ @@ -67,9 +85,16 @@ +
+ (source = 'all')}>{t('settings.marketplace.all')} + (source = 'jucode')}>JuCode + (source = 'anthropic')}>Anthropic + (source = 'installed')}>{t('settings.marketplace.installed')} +
+ {#if tags.length} -
- (tag = '')}>{t('settings.marketplace.all')} +
+ (tag = '')}>{t('settings.marketplace.allTags')} {#each tags as tg (tg)} (tag = tg)}>{tg} {/each} @@ -79,32 +104,45 @@
{#if loading}
{t('common.loading')}
- {:else if error} -
{error.includes('401') || error.toLowerCase().includes('unauth') ? t('settings.marketplace.needLogin') : t('settings.marketplace.loadFailed', { error })}
- {:else if filtered.length === 0} -
{t('settings.marketplace.noMatch')}
{:else} -
- {#each filtered as s (s.id)} -
-
- {s.name} - {#if s.isDefault}{t('settings.account.default')}{/if} -
-

{s.description}

-
-
- {#each s.tags.slice(0, 3) as tg (tg)}{tg}{/each} + {#if error}
{t('settings.marketplace.loadFailed', { error })}
{/if} + {#each warnings as warning (warning)} +
{warning.includes('401') || warning.toLowerCase().includes('unauth') ? t('settings.marketplace.needLogin') : warning}
+ {/each} +
{t('settings.marketplace.licenseNotice')}
+ {#if filtered.length === 0} +
{t('settings.marketplace.noMatch')}
+ {:else} +
+ {#each filtered as s (`${s.source}:${s.id}`)} +
+
+ {s.name} + {s.source === 'anthropic' ? 'Anthropic' : 'JuCode'} + {#if s.isDefault}{t('settings.account.default')}{/if} +
+

{s.description}

+ {#if !s.redistributable}

{t('settings.marketplace.sourceAvailable')}

{/if} +
+
+ {#each s.tags.slice(0, 3) as tg (tg)}{tg}{/each} +
+
-
-
- {/each} -
+ {/each} +
+ {/if} {/if}
+ {#if installDir}
{t('settings.marketplace.installDir', { path: installDir })}
{/if}
@@ -180,7 +218,37 @@ display: flex; flex-wrap: wrap; gap: 6px; - padding: 4px 20px 10px; + padding: 4px 20px; + } + .chips.sources { + padding-top: 2px; + } + .chips.tags { + padding-bottom: 10px; + } + .license-note { + margin: 0 0 12px; + padding: 9px 11px; + border: 1px solid var(--hairline); + border-radius: var(--r-md); + background: var(--surface2); + color: var(--dim); + font-size: 11.5px; + line-height: 1.45; + } + .notice { + margin: 0 0 8px; + padding: 8px 10px; + border-radius: var(--r-md); + font-size: 12px; + } + .notice.warn { + color: var(--warn); + background: color-mix(in oklab, var(--warn) 10%, transparent); + } + .notice.err { + color: var(--err); + background: color-mix(in oklab, var(--err) 10%, transparent); } .body { flex: 1; @@ -221,8 +289,12 @@ border-radius: 999px; padding: 1px 8px; } + .source { + font-size: 10px; + color: var(--dim2); + } .desc { - margin: 8px 0 12px; + margin: 8px 0 10px; font-size: 12.5px; line-height: 1.5; color: var(--dim); @@ -233,6 +305,12 @@ -webkit-box-orient: vertical; overflow: hidden; } + .restricted { + margin: -2px 0 10px; + color: var(--warn); + font-size: 10.5px; + line-height: 1.35; + } .card-foot { display: flex; align-items: center; @@ -266,4 +344,15 @@ .state.err { color: var(--err); } + .install-dir { + flex-shrink: 0; + padding: 8px 20px; + border-top: 1px solid var(--hairline); + color: var(--dim2); + font-family: var(--font-mono); + font-size: 10.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index 3d41050..406e36c 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -219,14 +219,21 @@ const settings = { }, marketplace: { title: '扩展市场', - subtitle: '为 JuCode 安装技能扩展。', + subtitle: '从 JuCode 与 Anthropic 安装技能扩展。', search: '搜索扩展…', all: '全部', + allTags: '全部标签', + sourceFilter: '技能来源', needLogin: '需要登录 JuCode 账号后才能浏览市场(设置 → 登录)。', loadFailed: '加载失败:{error}', noMatch: '没有匹配的扩展', installing: '安装中', - install: '安装' + install: '安装', + installed: '已安装', + installDir: '安装到 {path}', + sourceAvailable: '仅源码可见 · 不可再分发', + licenseNotice: + 'Anthropic 的 docx、pdf、pptx 与 xlsx 技能仅源码可见且不可再分发。Claude Code 不提供 API 的预置文档技能;此处安装的是仓库中的自定义技能副本,并受 Anthropic 条款约束。' }, overview: { dailyTitle: '每日 Token 用量', @@ -471,14 +478,21 @@ const settings = { }, marketplace: { title: 'Marketplace', - subtitle: 'Install skill extensions for JuCode.', + subtitle: 'Install skills from JuCode and Anthropic.', search: 'Search extensions…', all: 'All', + allTags: 'All tags', + sourceFilter: 'Skill source', needLogin: 'Log in to your JuCode account to browse the marketplace (Settings → Log in).', loadFailed: 'Failed to load: {error}', noMatch: 'No matching extensions', installing: 'Installing', - install: 'Install' + install: 'Install', + installed: 'Installed', + installDir: 'Installing to {path}', + sourceAvailable: 'Source-available · redistribution prohibited', + licenseNotice: + 'Anthropic’s docx, pdf, pptx, and xlsx skills are source-available and not for redistribution. Claude Code does not include the API’s preset document skills; installs here are custom repository copies governed by Anthropic’s terms.' }, overview: { dailyTitle: 'Daily token usage', diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index 1fb6756..8895716 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -117,27 +117,30 @@ export function removeAuthKey(provider: string): Promise { return invoke('remove_auth_key', { provider }); } -// Skills marketplace (Tauri fetches it directly from the JuCode API). +// Skills marketplace (Tauri combines JuCode with github.com/anthropics/skills). +export type SkillSource = 'jucode' | 'anthropic'; export interface MarketSkill { id: string; name: string; description: string; tags: string[]; + source: SkillSource; isDefault: boolean; + installed: boolean; + license: string; + redistributable: boolean; + homepage: string; } -export async function fetchMarketplace(): Promise { - const v = await invoke<{ skills?: unknown[]; default_skill_ids?: unknown[] }>('fetch_marketplace'); - const defaults = new Set((Array.isArray(v.default_skill_ids) ? v.default_skill_ids : []).map(String)); - return (Array.isArray(v.skills) ? v.skills : []) - .map((s) => s as Record) - .filter((s) => s.enabled !== false) - .map((s) => ({ - id: String(s.id ?? ''), - name: String(s.name ?? s.id ?? ''), - description: String(s.description ?? ''), - tags: Array.isArray(s.tags) ? (s.tags as unknown[]).map(String) : [], - isDefault: defaults.has(String(s.id)) - })); +export interface SkillCatalog { + skills: MarketSkill[]; + warnings: string[]; + installDir: string; +} +export function fetchMarketplace(backend: string): Promise { + return invoke('fetch_marketplace', { backend }); +} +export function installMarketplaceSkill(source: SkillSource, id: string, backend: string): Promise { + return invoke('install_marketplace_skill', { source, id, backend }); } // JuCode account: plan / balance / usage / call-details, fetched via the diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 17b2208..a29902b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1414,7 +1414,7 @@ {/if} {#if showMarket} - (showMarket = false)} /> + (showMarket = false)} /> {/if} {#if showSetup && activeId} From 73db36f964d44739fd53934b79c0d996fbde8075 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 03:01:56 +0000 Subject: [PATCH 2/3] Fix SHA-256 formatting Co-authored-by: Gao Yu --- src-tauri/src/skills.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/skills.rs b/src-tauri/src/skills.rs index bdee795..6753a8f 100644 --- a/src-tauri/src/skills.rs +++ b/src-tauri/src/skills.rs @@ -528,7 +528,10 @@ fn read_bounded(reader: impl Read, limit: usize) -> io::Result> { } fn verify_sha256(bytes: &[u8], expected: &str) -> io::Result<()> { - let actual = format!("{:x}", Sha256::digest(bytes)); + let actual = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); if actual.eq_ignore_ascii_case(expected.trim()) { Ok(()) } else { From e25323024815ac3b1d404ba78fee509bb7b640ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 03:02:33 +0000 Subject: [PATCH 3/3] Remove stale marketplace style Co-authored-by: Gao Yu --- src/lib/Marketplace.svelte | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/Marketplace.svelte b/src/lib/Marketplace.svelte index 7fbae36..6234c9a 100644 --- a/src/lib/Marketplace.svelte +++ b/src/lib/Marketplace.svelte @@ -341,9 +341,6 @@ font-size: 14px; text-align: center; } - .state.err { - color: var(--err); - } .install-dir { flex-shrink: 0; padding: 8px 20px;