From 97e8ac6d4a0f886ea782a764e9083f84f0d70bf7 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 18 Feb 2026 18:35:35 +0800 Subject: [PATCH] release: v0.69.0 - upgrade aster-rust to v0.13.0, skill content dialog --- package.json | 2 +- src-tauri/Cargo.lock | 47 +++--- src-tauri/Cargo.toml | 12 +- src-tauri/src/app/runner.rs | 1 + src-tauri/src/commands/skill_cmd.rs | 143 +++++++++++++++++- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/skills/SkillCard.test.ts | 37 ++++- src/components/skills/SkillCard.tsx | 46 +++++- .../skills/SkillContentDialog.test.tsx | 76 ++++++++++ src/components/skills/SkillContentDialog.tsx | 89 +++++++++++ src/components/skills/SkillsPage.tsx | 73 ++++++++- src/components/skills/index.ts | 1 + src/lib/api/skills.ts | 14 ++ 14 files changed, 507 insertions(+), 38 deletions(-) create mode 100644 src/components/skills/SkillContentDialog.test.tsx create mode 100644 src/components/skills/SkillContentDialog.tsx diff --git a/package.json b/package.json index a893a0eac..c120d4153 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.67.0", + "version": "0.69.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f2907f29..c328405a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -211,12 +211,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "aster" -version = "0.11.0" -source = "git+https://github.com/astercloud/aster-rust?tag=v0.11.0#c4a9c40b48bcb0c77375b10ccb1313366fb4e7ba" +name = "aster-core" +version = "0.13.0" +source = "git+https://github.com/astercloud/aster-rust?tag=v0.13.0#4422f761c3701d2e3af1b0e6db5d4394f80d9c54" dependencies = [ "ahash", "anyhow", + "aster-models", "async-stream", "async-trait", "aws-config", @@ -304,8 +305,8 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.12.0" -source = "git+https://github.com/astercloud/aster-rust?tag=v0.12.0#f6c1ad342eb58accb61524985fa78ea7f4b8be2b" +version = "0.13.0" +source = "git+https://github.com/astercloud/aster-rust?tag=v0.13.0#4422f761c3701d2e3af1b0e6db5d4394f80d9c54" dependencies = [ "serde", "serde_json", @@ -6684,11 +6685,11 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.68.0" +version = "0.69.0" dependencies = [ "anyhow", "arboard", - "aster", + "aster-core", "async-stream", "async-trait", "axum 0.7.9", @@ -6782,9 +6783,9 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.68.0" +version = "0.69.0" dependencies = [ - "aster", + "aster-core", "async-trait", "chrono", "dirs 5.0.1", @@ -6805,7 +6806,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.68.0" +version = "0.69.0" dependencies = [ "async-trait", "parking_lot", @@ -6821,7 +6822,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.68.0" +version = "0.69.0" dependencies = [ "aster-models", "async-trait", @@ -6861,7 +6862,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.68.0" +version = "0.69.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -6896,7 +6897,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.68.0" +version = "0.69.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6916,7 +6917,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.68.0" +version = "0.69.0" dependencies = [ "async-trait", "glob", @@ -6947,7 +6948,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.68.0" +version = "0.69.0" dependencies = [ "async-trait", "parking_lot", @@ -6966,7 +6967,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.68.0" +version = "0.69.0" dependencies = [ "anyhow", "async-stream", @@ -7018,7 +7019,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.68.0" +version = "0.69.0" dependencies = [ "async-stream", "axum 0.7.9", @@ -7060,7 +7061,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.68.0" +version = "0.69.0" dependencies = [ "axum 0.7.9", "futures", @@ -7075,10 +7076,10 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.68.0" +version = "0.69.0" dependencies = [ "anyhow", - "aster", + "aster-core", "async-trait", "base64 0.22.1", "chrono", @@ -7116,7 +7117,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.68.0" +version = "0.69.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7132,7 +7133,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.68.0" +version = "0.69.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7159,7 +7160,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.68.0" +version = "0.69.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8ce4c505..48bcd5f8b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.68.0" +version = "0.69.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -118,11 +118,11 @@ enigo = "0.3" # Aster Agent Framework # 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库 # 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/) -# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.11.0" -# aster = { path = "../../../astercloud/aster-rust/crates/aster" } -aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.11.0" } +# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" +# aster = { package = "aster-core", path = "../../../astercloud/aster-rust/crates/aster" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" } # 本地开发: aster-models = { path = "../../../astercloud/aster-rust/crates/aster-models" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.12.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -185,7 +185,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.68.0" +version = "0.69.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 75611a113..840b8a80a 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -756,6 +756,7 @@ pub fn run() { commands::skill_cmd::add_skill_repo, commands::skill_cmd::remove_skill_repo, commands::skill_cmd::get_installed_proxycast_skills, + commands::skill_cmd::get_local_skill_content, // Skill Execution commands commands::skill_exec_cmd::execute_skill, commands::skill_exec_cmd::list_executable_skills, diff --git a/src-tauri/src/commands/skill_cmd.rs b/src-tauri/src/commands/skill_cmd.rs index 6f11c91df..7bb5e16c3 100644 --- a/src-tauri/src/commands/skill_cmd.rs +++ b/src-tauri/src/commands/skill_cmd.rs @@ -5,7 +5,7 @@ use crate::models::app_type::AppType; use crate::models::skill_model::{Skill, SkillRepo, SkillState}; use chrono::Utc; use proxycast_services::skill_service::SkillService; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use tauri::State; @@ -42,6 +42,75 @@ pub fn scan_installed_skills(skills_dir: &Path) -> Vec { skills } +fn get_skills_dir(app_type: &AppType) -> Result { + let home = dirs::home_dir().ok_or_else(|| "Failed to get home directory".to_string())?; + + let skills_dir = match app_type { + AppType::Claude => home.join(".claude").join("skills"), + AppType::Codex => home.join(".codex").join("skills"), + AppType::Gemini => home.join(".gemini").join("skills"), + AppType::ProxyCast => home.join(".proxycast").join("skills"), + }; + + Ok(skills_dir) +} + +fn validate_skill_directory(directory: &str) -> Result<(), String> { + if directory.trim().is_empty() { + return Err("Skill directory is required".to_string()); + } + + if directory.contains("..") || directory.contains('/') || directory.contains('\\') { + return Err("Invalid skill directory".to_string()); + } + + let mut components = Path::new(directory).components(); + let first = components + .next() + .ok_or_else(|| "Skill directory is required".to_string())?; + + if components.next().is_some() { + return Err("Invalid skill directory".to_string()); + } + + match first { + Component::Normal(_) => Ok(()), + _ => Err("Invalid skill directory".to_string()), + } +} + +fn read_local_skill_content(skills_dir: &Path, directory: &str) -> Result { + validate_skill_directory(directory)?; + + if !skills_dir.exists() { + return Err("Skills directory not found".to_string()); + } + + let canonical_skills_dir = skills_dir + .canonicalize() + .map_err(|e| format!("Failed to resolve skills directory: {e}"))?; + + let skill_dir = skills_dir.join(directory); + if !skill_dir.exists() { + return Err(format!("Skill not found: {directory}")); + } + + let canonical_skill_dir = skill_dir + .canonicalize() + .map_err(|e| format!("Failed to resolve skill directory: {e}"))?; + + if !canonical_skill_dir.starts_with(&canonical_skills_dir) { + return Err("Invalid skill directory path".to_string()); + } + + let skill_md_path = canonical_skill_dir.join("SKILL.md"); + if !skill_md_path.is_file() { + return Err(format!("SKILL.md not found for skill: {directory}")); + } + + std::fs::read_to_string(&skill_md_path).map_err(|e| format!("Failed to read SKILL.md: {e}")) +} + /// 获取已安装的 ProxyCast Skills 目录列表 /// /// 扫描 ~/.proxycast/skills/ 目录,返回包含 SKILL.md 的子目录名列表。 @@ -57,6 +126,24 @@ pub async fn get_installed_proxycast_skills() -> Result, String> { Ok(scan_installed_skills(&skills_dir)) } +/// 获取本地已安装 Skill 的 SKILL.md 内容 +/// +/// 仅支持读取本地 Skills 目录下的文件,包含目录合法性与路径穿越防护。 +/// +/// # Arguments +/// - `app`: 应用类型(proxycast/claude/codex/gemini) +/// - `directory`: Skill 目录名 +/// +/// # Returns +/// - `Ok(String)`: SKILL.md 的文本内容 +/// - `Err(String)`: 错误信息 +#[tauri::command] +pub fn get_local_skill_content(app: String, directory: String) -> Result { + let app_type: AppType = app.parse().map_err(|e: String| e)?; + let skills_dir = get_skills_dir(&app_type)?; + read_local_skill_content(&skills_dir, &directory) +} + pub struct SkillServiceState(pub Arc); fn get_skill_key(app_type: &AppType, directory: &str) -> String { @@ -374,4 +461,58 @@ mod tests { assert_eq!(discovered.len(), 1); assert!(discovered.contains(&"valid-skill".to_string())); } + + #[test] + fn test_read_local_skill_content_success() { + let temp_dir = TempDir::new().unwrap(); + let skills_dir = temp_dir.path().join("skills"); + let skill_dir = skills_dir.join("demo-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Demo Skill\ncontent").unwrap(); + + let content = read_local_skill_content(&skills_dir, "demo-skill").unwrap(); + assert!(content.contains("# Demo Skill")); + assert!(content.contains("content")); + } + + #[test] + fn test_read_local_skill_content_rejects_traversal() { + let temp_dir = TempDir::new().unwrap(); + let skills_dir = temp_dir.path().join("skills"); + std::fs::create_dir_all(&skills_dir).unwrap(); + + let err = read_local_skill_content(&skills_dir, "../outside").unwrap_err(); + assert!(err.contains("Invalid skill directory")); + } + + #[test] + fn test_read_local_skill_content_missing_skill_md() { + let temp_dir = TempDir::new().unwrap(); + let skills_dir = temp_dir.path().join("skills"); + let skill_dir = skills_dir.join("no-skill-md"); + std::fs::create_dir_all(&skill_dir).unwrap(); + + let err = read_local_skill_content(&skills_dir, "no-skill-md").unwrap_err(); + assert!(err.contains("SKILL.md not found")); + } + + #[cfg(unix)] + #[test] + fn test_read_local_skill_content_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let temp_dir = TempDir::new().unwrap(); + let skills_dir = temp_dir.path().join("skills"); + std::fs::create_dir_all(&skills_dir).unwrap(); + + let outside_dir = temp_dir.path().join("outside-skill"); + std::fs::create_dir_all(&outside_dir).unwrap(); + std::fs::write(outside_dir.join("SKILL.md"), "# Outside").unwrap(); + + let symlink_dir = skills_dir.join("escape-skill"); + symlink(&outside_dir, &symlink_dir).unwrap(); + + let err = read_local_skill_content(&skills_dir, "escape-skill").unwrap_err(); + assert!(err.contains("Invalid skill directory path")); + } } diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 8370ad216..69e72df8a 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.67.0", + "version": "0.69.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b42926a77..3f7455333 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.67.0", + "version": "0.69.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/skills/SkillCard.test.ts b/src/components/skills/SkillCard.test.ts index 6be5819d2..a02d4b595 100644 --- a/src/components/skills/SkillCard.test.ts +++ b/src/components/skills/SkillCard.test.ts @@ -7,10 +7,14 @@ * **Validates: Requirements 5.1, 5.2** */ -import { describe, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { test } from "@fast-check/vitest"; import * as fc from "fast-check"; -import { getSkillSource, type SkillSource } from "./SkillCard"; +import { + canViewLocalSkillContent, + getSkillSource, + type SkillSource, +} from "./SkillCard"; import type { Skill } from "@/lib/api/skills"; /** @@ -130,3 +134,32 @@ describe("getSkillSource", () => { ); }); }); + +describe("canViewLocalSkillContent", () => { + it("本地且已安装 skill 应可查看内容", () => { + const skill = createSkill({ + installed: true, + repoOwner: undefined, + repoName: undefined, + }); + expect(canViewLocalSkillContent(skill)).toBe(true); + }); + + it("本地但未安装 skill 不可查看内容", () => { + const skill = createSkill({ + installed: false, + repoOwner: undefined, + repoName: undefined, + }); + expect(canViewLocalSkillContent(skill)).toBe(false); + }); + + it("非本地已安装 skill 不可查看内容", () => { + const skill = createSkill({ + installed: true, + repoOwner: "proxycast", + repoName: "skills", + }); + expect(canViewLocalSkillContent(skill)).toBe(false); + }); +}); diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index fdfc6a143..86e641c2f 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -6,13 +6,21 @@ * - 显示 Skill 基本信息(名称、描述、来源) * - 安装/卸载操作按钮 * - 执行按钮(仅已安装的 Skill 显示) + * - 查看内容按钮(仅本地且已安装的 Skill 显示) * - GitHub 链接按钮 * * @module components/skills * @requirements 6.1, 6.3 */ -import { Download, Trash2, ExternalLink, Loader2, Play } from "lucide-react"; +import { + Download, + Trash2, + ExternalLink, + Loader2, + Play, + FileText, +} from "lucide-react"; import type { Skill } from "@/lib/api/skills"; /** @@ -45,6 +53,19 @@ export function getSkillSource(skill: Skill): SkillSource { return "community"; } +/** + * 是否可查看本地 Skill 内容 + * + * 仅本地且已安装的 Skill 支持查看 SKILL.md。 + * + * @param skill - Skill 对象 + * @returns 是否显示查看内容入口 + */ +// eslint-disable-next-line react-refresh/only-export-components +export function canViewLocalSkillContent(skill: Skill): boolean { + return skill.installed && getSkillSource(skill) === "local"; +} + /** * 来源标签配置 */ @@ -90,6 +111,7 @@ interface SkillCardProps { onInstall: (directory: string) => void; onUninstall: (directory: string) => void; onExecute?: (skill: Skill) => void; + onViewContent?: (skill: Skill) => void; installing: boolean; } @@ -99,6 +121,7 @@ interface SkillCardProps { * 展示单个 Skill 的信息和操作按钮,包括: * - 安装/卸载按钮 * - 执行按钮(仅已安装的 Skill 显示) + * - 查看内容按钮(仅本地且已安装的 Skill 显示) * - GitHub 链接按钮 * * @param props - 组件属性 @@ -111,6 +134,7 @@ export function SkillCard({ onInstall, onUninstall, onExecute, + onViewContent, installing, }: SkillCardProps) { const handleAction = () => { @@ -138,7 +162,14 @@ export function SkillCard({ } }; + const handleViewContent = () => { + if (onViewContent && canViewLocalSkillContent(skill)) { + onViewContent(skill); + } + }; + const source = getSkillSource(skill); + const showViewContent = Boolean(onViewContent && canViewLocalSkillContent(skill)); return (
@@ -210,6 +241,19 @@ export function SkillCard({ )} + {/* 查看内容按钮 - 仅本地且已安装的 Skill 显示 */} + {showViewContent && ( + + )} + {skill.readmeUrl && (
); }, diff --git a/src/components/skills/index.ts b/src/components/skills/index.ts index 523853bc8..2f761fd1f 100644 --- a/src/components/skills/index.ts +++ b/src/components/skills/index.ts @@ -11,3 +11,4 @@ export { SkillExecutionDialog, type SkillExecutionDialogProps, } from "./SkillExecutionDialog"; +export { SkillContentDialog, type SkillContentDialogProps } from "./SkillContentDialog"; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index ad7fa0300..c19d3d64e 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -63,4 +63,18 @@ export const skillsApi = { async getInstalledProxyCastSkills(): Promise { return safeInvoke("get_installed_proxycast_skills"); }, + + /** + * 获取本地已安装 Skill 的 SKILL.md 内容 + * + * @param directory Skill 目录名 + * @param app 应用类型 + * @returns SKILL.md 的文本内容 + */ + async getLocalSkillContent( + directory: string, + app: AppType = "proxycast", + ): Promise { + return safeInvoke("get_local_skill_content", { app, directory }); + }, };