Manage skills as sources over ACP (#8675)

Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Jack Amadeo
2026-04-20 23:09:21 -04:00
committed by GitHub
parent 93299b513c
commit b1235e7c01
18 changed files with 1622 additions and 363 deletions
+7
View File
@@ -1,7 +1,14 @@
use std::env;
use crate::services::acp::GooseServeProcess;
#[tauri::command]
pub async fn get_goose_serve_url(app_handle: tauri::AppHandle) -> Result<String, String> {
if let Ok(url) = env::var("GOOSE_SERVE_URL") {
if !url.is_empty() {
return Ok(url);
}
}
let process = GooseServeProcess::get(app_handle).await?;
Ok(process.ws_url())
}
-1
View File
@@ -9,5 +9,4 @@ pub mod git_changes;
pub mod model_setup;
pub mod path_resolver;
pub mod projects;
pub mod skills;
pub mod system;
-319
View File
@@ -1,319 +0,0 @@
use std::fs;
use std::path::PathBuf;
fn skills_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
Ok(home.join(".agents").join("skills"))
}
/// Validates that a skill name is kebab-case only: `^[a-z0-9]+(-[a-z0-9]+)*$`.
/// This prevents path traversal attacks (e.g. `../../.ssh/authorized_keys`).
fn validate_skill_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Skill name must not be empty".to_string());
}
let mut expect_alnum = true; // true = next char must be [a-z0-9], false = can also be '-'
for ch in name.chars() {
if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
expect_alnum = false;
} else if ch == '-' && !expect_alnum {
expect_alnum = true; // char after '-' must be [a-z0-9]
} else {
return Err(format!(
"Invalid skill name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \
must not start or end with a hyphen or contain consecutive hyphens).",
name
));
}
}
if expect_alnum {
// name ended with '-'
return Err(format!(
"Invalid skill name \"{}\". Names must not end with a hyphen.",
name
));
}
Ok(())
}
fn build_skill_md(name: &str, description: &str, instructions: &str) -> String {
// Escape embedded single quotes by doubling them, then wrap in single quotes
// to prevent YAML injection in the description field.
let safe_desc = description.replace('\'', "''");
let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc);
if !instructions.is_empty() {
md.push('\n');
md.push_str(instructions);
md.push('\n');
}
md
}
#[tauri::command]
pub fn create_skill(name: String, description: String, instructions: String) -> Result<(), String> {
validate_skill_name(&name)?;
let dir = skills_dir()?.join(&name);
if dir.exists() {
return Err(format!("A skill named \"{}\" already exists", name));
}
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?;
let skill_path = dir.join("SKILL.md");
let content = build_skill_md(&name, &description, &instructions);
fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?;
Ok(())
}
#[tauri::command]
pub fn list_skills() -> Result<Vec<SkillInfo>, String> {
let dir = skills_dir()?;
if !dir.exists() {
return Ok(vec![]);
}
let mut skills = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read skills dir: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let raw = fs::read_to_string(&skill_md).unwrap_or_default();
let (description, instructions) = parse_frontmatter(&raw);
skills.push(SkillInfo {
name,
description,
instructions,
path: skill_md.to_string_lossy().to_string(),
});
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(skills)
}
#[tauri::command]
pub fn delete_skill(name: String) -> Result<(), String> {
validate_skill_name(&name)?;
let dir = skills_dir()?.join(&name);
if !dir.exists() {
return Err(format!("Skill \"{}\" not found", name));
}
fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete skill: {}", e))?;
Ok(())
}
fn parse_frontmatter(raw: &str) -> (String, String) {
let trimmed = raw.trim();
if !trimmed.starts_with("---") {
return (String::new(), raw.to_string());
}
if let Some(end) = trimmed[3..].find("\n---") {
let front = &trimmed[3..3 + end].trim();
let body = trimmed[3 + end + 4..].trim().to_string();
let mut description = String::new();
for line in front.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("description:") {
let val = rest.trim();
// Strip surrounding quotes (single or double)
let unquoted = val
.trim_start_matches(['\'', '"'])
.trim_end_matches(['\'', '"']);
description = if val.starts_with('\'') {
// Un-escape doubled single quotes
unquoted.replace("''", "'")
} else {
// Legacy double-quote format
unquoted.replace("\\\"", "\"")
}
.to_string();
}
}
(description, body)
} else {
(String::new(), raw.to_string())
}
}
#[derive(serde::Serialize, Clone)]
pub struct SkillInfo {
pub name: String,
pub description: String,
pub instructions: String,
pub path: String,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillExportV1 {
version: u32,
name: String,
description: String,
#[serde(skip_serializing_if = "String::is_empty")]
instructions: String,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportSkillResult {
json: String,
filename: String,
}
#[tauri::command]
pub fn update_skill(
name: String,
description: String,
instructions: String,
) -> Result<SkillInfo, String> {
validate_skill_name(&name)?;
let dir = skills_dir()?.join(&name);
if !dir.exists() {
return Err(format!("Skill \"{}\" not found", name));
}
let skill_path = dir.join("SKILL.md");
let content = build_skill_md(&name, &description, &instructions);
fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?;
Ok(SkillInfo {
name: name.clone(),
description,
instructions,
path: skill_path.to_string_lossy().to_string(),
})
}
#[tauri::command]
pub fn export_skill(name: String) -> Result<ExportSkillResult, String> {
validate_skill_name(&name)?;
let dir = skills_dir()?.join(&name);
if !dir.exists() {
return Err(format!("Skill \"{}\" not found", name));
}
let skill_md = dir.join("SKILL.md");
let raw =
fs::read_to_string(&skill_md).map_err(|e| format!("Failed to read SKILL.md: {}", e))?;
let (description, instructions) = parse_frontmatter(&raw);
let export = SkillExportV1 {
version: 1,
name: name.clone(),
description,
instructions,
};
let json = serde_json::to_string_pretty(&export)
.map_err(|e| format!("Failed to serialize skill: {}", e))?;
let filename = format!("{}.skill.json", name);
Ok(ExportSkillResult { json, filename })
}
#[tauri::command]
pub fn import_skills(file_bytes: Vec<u8>, file_name: String) -> Result<Vec<SkillInfo>, String> {
// Validate file extension
if !file_name.ends_with(".skill.json") && !file_name.ends_with(".json") {
return Err("File must have a .skill.json or .json extension".to_string());
}
// Parse bytes as UTF-8
let text =
String::from_utf8(file_bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))?;
// Parse as JSON
let value: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?;
// Validate version
let version = value
.get("version")
.and_then(|v| v.as_u64())
.ok_or("Missing or invalid \"version\" field")?;
if version != 1 {
return Err(format!("Unsupported skill export version: {}", version));
}
// Extract fields
let name = value
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing or invalid \"name\" field")?
.to_string();
if name.is_empty() {
return Err("Skill name must not be empty".to_string());
}
let description = value
.get("description")
.and_then(|v| v.as_str())
.ok_or("Missing or invalid \"description\" field")?
.to_string();
if description.is_empty() {
return Err("Skill description must not be empty".to_string());
}
let instructions = value
.get("instructions")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Validate the name
validate_skill_name(&name)?;
// Determine final name, avoiding collisions
let base_dir = skills_dir()?;
let mut final_name = name.clone();
if base_dir.join(&final_name).exists() {
final_name = format!("{}-imported", name);
// If that also exists, append a number
let mut counter = 2u32;
while base_dir.join(&final_name).exists() {
final_name = format!("{}-imported-{}", name, counter);
counter += 1;
}
}
// Create the skill on disk
let dir = base_dir.join(&final_name);
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?;
let skill_path = dir.join("SKILL.md");
let content = build_skill_md(&final_name, &description, &instructions);
fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?;
Ok(vec![SkillInfo {
name: final_name,
description,
instructions,
path: skill_path.to_string_lossy().to_string(),
}])
}
-6
View File
@@ -44,12 +44,6 @@ pub fn run() {
commands::agents::save_persona_avatar_bytes,
commands::agents::get_avatars_dir,
commands::acp::get_goose_serve_url,
commands::skills::create_skill,
commands::skills::list_skills,
commands::skills::delete_skill,
commands::skills::update_skill,
commands::skills::export_skill,
commands::skills::import_skills,
commands::projects::list_projects,
commands::projects::create_project,
commands::projects::update_project,
+64 -9
View File
@@ -1,4 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { getClient } from "@/shared/api/acpConnection";
export interface SkillInfo {
name: string;
@@ -7,20 +7,54 @@ export interface SkillInfo {
path: string;
}
// Shape returned by _goose/sources/*. Narrowed to skill-type sources here.
interface SourceEntry {
type: "skill";
name: string;
description: string;
content: string;
directory: string;
global: boolean;
}
function toSkillInfo(source: SourceEntry): SkillInfo {
return {
name: source.name,
description: source.description,
instructions: source.content,
path: source.directory,
};
}
export async function createSkill(
name: string,
description: string,
instructions: string,
): Promise<void> {
return invoke("create_skill", { name, description, instructions });
const client = await getClient();
await client.extMethod("_goose/sources/create", {
type: "skill",
name,
description,
content: instructions,
global: true,
});
}
export async function listSkills(): Promise<SkillInfo[]> {
return invoke("list_skills");
const client = await getClient();
const raw = await client.extMethod("_goose/sources/list", { type: "skill" });
const sources = (raw.sources ?? []) as SourceEntry[];
return sources.map(toSkillInfo);
}
export async function deleteSkill(name: string): Promise<void> {
return invoke("delete_skill", { name });
const client = await getClient();
await client.extMethod("_goose/sources/delete", {
type: "skill",
name,
global: true,
});
}
export async function updateSkill(
@@ -28,21 +62,42 @@ export async function updateSkill(
description: string,
instructions: string,
): Promise<SkillInfo> {
return invoke("update_skill", { name, description, instructions });
const client = await getClient();
const raw = await client.extMethod("_goose/sources/update", {
type: "skill",
name,
description,
content: instructions,
global: true,
});
return toSkillInfo(raw.source as SourceEntry);
}
export async function exportSkill(
name: string,
): Promise<{ json: string; filename: string }> {
return invoke("export_skill", { name });
const client = await getClient();
const raw = await client.extMethod("_goose/sources/export", {
type: "skill",
name,
global: true,
});
return { json: raw.json as string, filename: raw.filename as string };
}
export async function importSkills(
fileBytes: number[],
fileName: string,
): Promise<SkillInfo[]> {
return invoke("import_skills", {
fileBytes: Array.from(fileBytes),
fileName,
if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) {
throw new Error("File must have a .skill.json or .json extension");
}
const data = new TextDecoder().decode(new Uint8Array(fileBytes));
const client = await getClient();
const raw = await client.extMethod("_goose/sources/import", {
data,
global: true,
});
const sources = (raw.sources ?? []) as SourceEntry[];
return sources.map(toSkillInfo);
}
+52 -25
View File
@@ -2,6 +2,10 @@
* Playwright custom fixture that injects a Tauri IPC mock into the page
* before every navigation. This allows E2E tests to run against the frontend
* without the real Tauri backend.
*
* Also installs a `window.WebSocket` stub for the ACP connection so features
* like skills (which use `client.extMethod("_goose/sources/...")`) can run
* without a live goose-acp server.
*/
import { test as base, expect, type Page } from "@playwright/test";
@@ -11,7 +15,7 @@ import { MOCK_PERSONAS, MOCK_PROJECTS, MOCK_SKILLS } from "./mock-data";
* Build the init script that will be injected into the page via
* `page.addInitScript()`. The script sets up `window.__TAURI_INTERNALS__`
* with an `invoke` handler that returns mock data for every Tauri command
* the app is known to call.
* the app is known to call, plus a WebSocket mock for ACP traffic.
*
* Callers can override the default personas and skills arrays to test
* empty-state or custom scenarios.
@@ -30,8 +34,18 @@ export function buildInitScript(options?: {
const PERSONAS = ${personas};
const SKILLS = ${skills};
const PROJECTS = ${projects};
const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp";
const ACP_SESSIONS = [];
const skillToSourceEntry = (s) => ({
type: "skill",
name: s.name,
description: s.description,
content: s.instructions ?? s.content ?? "",
directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""),
global: true,
});
function nowIso() {
return new Date().toISOString();
}
@@ -120,6 +134,39 @@ export function buildInitScript(options?: {
case "_goose/working_dir/update":
case "goose/working_dir/update":
return jsonRpcResult(message.id, {});
case "_goose/sources/list":
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
case "_goose/sources/create":
return jsonRpcResult(message.id, {
source: {
name: message.params?.name ?? "new-skill",
type: "skill",
description: message.params?.description ?? "",
content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
global: message.params?.global ?? true,
},
});
case "_goose/sources/update":
return jsonRpcResult(message.id, {
source: {
name: message.params?.name ?? "updated-skill",
type: "skill",
description: message.params?.description ?? "",
content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "updated-skill"),
global: message.params?.global ?? true,
},
});
case "_goose/sources/delete":
return jsonRpcResult(message.id, {});
case "_goose/sources/export":
return jsonRpcResult(message.id, {
json: "{}",
filename: (message.params?.name ?? "skill") + ".skill.json",
});
case "_goose/sources/import":
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
default:
return jsonRpcResult(message.id, {});
}
@@ -165,6 +212,10 @@ export function buildInitScript(options?: {
window.__TAURI_INTERNALS__ = {
invoke(cmd, args) {
switch (cmd) {
// ---- ACP transport ----
case "get_goose_serve_url":
return Promise.resolve(FAKE_ACP_URL);
// ---- Personas ----
case "list_personas":
return Promise.resolve(PERSONAS);
@@ -202,28 +253,6 @@ export function buildInitScript(options?: {
case "import_personas":
return Promise.resolve(PERSONAS);
// ---- Skills ----
case "list_skills":
return Promise.resolve(SKILLS);
case "create_skill":
return Promise.resolve(null);
case "update_skill":
return Promise.resolve({
name: args?.name ?? "updated-skill",
description: args?.description ?? "",
instructions: args?.instructions ?? "",
path: "",
});
case "delete_skill":
return Promise.resolve(null);
case "export_skill":
return Promise.resolve({
json: "{}",
filename: "skill.json",
});
case "import_skills":
return Promise.resolve(SKILLS);
// ---- Sessions / Misc ----
case "list_sessions":
return Promise.resolve(
@@ -234,8 +263,6 @@ export function buildInitScript(options?: {
messageCount: session.messageCount,
})),
);
case "get_goose_serve_url":
return Promise.resolve("ws://mock-goose");
case "create_session":
return Promise.resolve({
id: "session-" + Math.random().toString(36).slice(2, 10),