From 62ef89a1634fd12a503156ce95baebbf037ef96f Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 16 Jul 2026 08:39:57 +0900 Subject: [PATCH] feat: add workspace-backed agent skills --- crates/session-store/src/system_item.rs | 6 + crates/tui/src/app.rs | 1 + crates/worker/src/lib.rs | 1 + crates/worker/src/skill.rs | 238 +++++++ crates/worker/src/worker.rs | 29 + crates/workspace-server/src/lib.rs | 1 + crates/workspace-server/src/main.rs | 84 ++- crates/workspace-server/src/server.rs | 117 +++- crates/workspace-server/src/skills.rs | 646 ++++++++++++++++++ resources/prompts/common/tool-usage.md | 4 + resources/skills/agent-skills/SKILL.md | 16 + .../src/lib/workspace/api/http.test.ts | 62 +- web/workspace/src/lib/workspace/api/http.ts | 102 ++- 13 files changed, 1295 insertions(+), 12 deletions(-) create mode 100644 crates/worker/src/skill.rs create mode 100644 crates/workspace-server/src/skills.rs create mode 100644 resources/skills/agent-skills/SKILL.md diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 7b406785..e4fe4da5 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -139,6 +139,10 @@ pub enum SystemItem { /// byte-identical to what was sent. FileAttachment { path: String, body: String }, + /// Explicit Agent Skill activation. `body` is the exact LLM-facing + /// Skill text committed before any subsequent LLM request can use it. + SkillActivation { name: String, body: String }, + /// Historical persisted Knowledge reference resolution. Knowledge is no /// longer active, so restored sessions ignore this item instead of replaying /// archived record text into model context. @@ -174,6 +178,7 @@ impl SystemItem { SystemItem::Notification { body, .. } => body.clone(), SystemItem::WorkerEvent { body, .. } => body.clone(), SystemItem::FileAttachment { body, .. } => body.clone(), + SystemItem::SkillActivation { body, .. } => body.clone(), SystemItem::LegacyKnowledgeIgnored { .. } => String::new(), SystemItem::LegacyIgnored { slug } => { format!("Ignored legacy procedure item: /{slug}") @@ -196,6 +201,7 @@ impl SystemItem { SystemItem::Notification { .. } => "notification", SystemItem::WorkerEvent { .. } => "worker_event", SystemItem::FileAttachment { .. } => "file_attachment", + SystemItem::SkillActivation { .. } => "skill_activation", SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored", SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::TaskReminder { .. } => "task_reminder", diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 70e12dd7..af2abaf0 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2163,6 +2163,7 @@ impl App { self.blocks.push(Block::WorkerEvent { event }); } session_store::SystemItem::FileAttachment { body, .. } + | session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::TaskReminder { body, .. } | session_store::SystemItem::Interrupt { body } => { self.task_store.apply_system_message_text(&body); diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 330515e1..cf682fbb 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -13,6 +13,7 @@ pub mod runtime; pub mod segment_log_sink; pub mod shared_state; mod shutdown_after_idle; +pub mod skill; pub mod spawn; mod interrupt_prep; diff --git a/crates/worker/src/skill.rs b/crates/worker/src/skill.rs new file mode 100644 index 00000000..2c430786 --- /dev/null +++ b/crates/worker/src/skill.rs @@ -0,0 +1,238 @@ +use serde::{Deserialize, Serialize}; + +use crate::worker::WorkspaceClient; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SkillDiagnosticSeverity { + Error, + Warning, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SkillDiagnostic { + pub severity: SkillDiagnosticSeverity, + pub code: String, + pub message: String, + /// Path-free authority/provenance label such as `builtin:foo` or `workspace:foo`. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +impl SkillDiagnostic { + pub fn error( + code: impl Into, + message: impl Into, + source: Option, + ) -> Self { + Self { + severity: SkillDiagnosticSeverity::Error, + code: code.into(), + message: message.into(), + source, + } + } + + pub fn warning( + code: impl Into, + message: impl Into, + source: Option, + ) -> Self { + Self { + severity: SkillDiagnosticSeverity::Warning, + code: code.into(), + message: message.into(), + source, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SkillSourceKind { + Builtin, + Workspace, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SkillProvenance { + pub kind: SkillSourceKind, + /// Stable path-free id: `builtin:` or `workspace:`. + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SkillResourceRef { + pub kind: String, + /// Skill-relative resource name/path. Never an absolute filesystem path. + pub name: String, + pub supported: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostic: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SkillCatalogEntry { + pub name: String, + pub description: String, + pub provenance: SkillProvenance, + #[serde(default)] + pub overrides: Vec, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SkillCatalogResponse { + /// Authority label for diagnostics; callers must not interpret it as a path. + pub authority: String, + #[serde(default)] + pub entries: Vec, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SkillDetailResponse { + pub name: String, + pub description: String, + pub provenance: SkillProvenance, + #[serde(default)] + pub overrides: Vec, + #[serde(default)] + pub diagnostics: Vec, + /// Full SKILL.md contents. This is intentionally omitted from catalog responses. + pub body: String, + #[serde(default)] + pub allowed_tools: Vec, + /// Explicitly documents that allowed-tools is parsed only as an experimental hint. + pub allowed_tools_status: String, + #[serde(default)] + pub resources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SkillActivationResponse { + pub name: String, + pub provenance: SkillProvenance, + #[serde(default)] + pub diagnostics: Vec, + /// Full SKILL.md contents to append to Worker history on explicit activation. + pub body: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum SkillClientError { + #[error("workspace client is unavailable: {0}")] + Unavailable(String), + #[error("workspace client kind `{0}` does not expose direct Skill HTTP operations")] + UnsupportedClient(String), + #[error("Skill request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("Skill API response JSON is invalid: {0}")] + Json(#[from] serde_json::Error), + #[error("Skill API returned HTTP {status}: {body}")] + Http { + status: reqwest::StatusCode, + body: String, + }, + #[error("invalid Skill API base URL: {0}")] + InvalidBaseUrl(String), +} + +impl WorkspaceClient { + pub fn list_skills(&self) -> Result { + self.get_skill_json("skills") + } + + pub fn read_skill(&self, name: &str) -> Result { + self.get_skill_json(&format!("skills/{name}")) + } + + pub fn activate_skill(&self, name: &str) -> Result { + self.get_skill_json(&format!("skills/{name}/activate")) + } + + fn get_skill_json Deserialize<'de>>( + &self, + path: &str, + ) -> Result { + let Self::Http { + workspace_id, + base_url, + } = self + else { + return match self { + Self::Available { kind } => Err(SkillClientError::UnsupportedClient(kind.clone())), + Self::Unavailable { reason } => Err(SkillClientError::Unavailable(reason.clone())), + Self::Http { .. } => unreachable!(), + }; + }; + if base_url.trim().is_empty() { + return Err(SkillClientError::InvalidBaseUrl(base_url.clone())); + } + let base = base_url.trim_end_matches('/'); + let url = format!("{base}/api/w/{workspace_id}/{path}"); + let response = reqwest::blocking::Client::new().get(url).send()?; + let status = response.status(); + let body = response.text()?; + if !status.is_success() { + return Err(SkillClientError::Http { status, body }); + } + Ok(serde_json::from_str(&body)?) + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::thread; + + use super::*; + + #[test] + fn http_workspace_client_fetches_skill_catalog_from_backend_endpoint() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + assert!(request_line.starts_with("GET /api/w/ws-1/skills HTTP/1.1")); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + } + let body = serde_json::json!({ + "authority": "workspace-backend-skills-v0", + "entries": [{ + "name": "triage-errors", + "description": "Use when triaging errors.", + "provenance": { "kind": "workspace", "id": "workspace:triage-errors" }, + "overrides": [], + "diagnostics": [] + }], + "diagnostics": [] + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + + let client = WorkspaceClient::http("ws-1", format!("http://{addr}")); + let catalog = client.list_skills().unwrap(); + assert_eq!(catalog.entries[0].name, "triage-errors"); + assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors"); + handle.join().unwrap(); + } +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 22fae41b..3b4f0d04 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -49,6 +49,7 @@ use crate::prompt::loader::PromptLoader; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::runtime::dir; use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError}; +use crate::skill::{SkillActivationResponse, SkillClientError}; #[cfg(test)] use async_trait::async_trait; use protocol::{ @@ -896,6 +897,31 @@ impl Worker { self.workspace_context.client() } + /// Activate an Agent Skill through the Workspace backend/client and commit + /// the returned SKILL.md body to history before it can influence an LLM run. + /// + /// This deliberately does not scan `.yoi/skills` locally: when a Workspace + /// HTTP client is available, catalog/detail/activation authority belongs to + /// the Workspace backend API. + pub fn activate_skill(&mut self, name: &str) -> Result { + let activation = self.workspace_client().activate_skill(name)?; + self.ensure_segment_head()?; + let body = format!( + "Agent Skill `{}` activated from {}.\n\n{}", + activation.name, activation.provenance.id, activation.body + ); + self.commit_entry(LogEntry::SystemItem { + ts: segment_log::now_millis(), + item: SystemItem::SkillActivation { + name: activation.name.clone(), + body: body.clone(), + }, + })?; + self.engine_mut() + .append_history(std::iter::once(llm_engine::Item::system_message(body))); + Ok(activation) + } + pub(crate) fn worker_metadata_store(&self) -> St where St: Clone, @@ -4789,6 +4815,9 @@ pub enum WorkerError { #[error(transparent)] PromptCatalog(#[from] CatalogError), + #[error(transparent)] + Skill(#[from] SkillClientError), + #[error("memory extract staging write failed: {0}")] ExtractStaging(#[source] memory::extract::StagingError), diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 5c8b11b2..83cd06bb 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -14,6 +14,7 @@ pub mod records; pub mod repositories; pub mod resource_broker; pub mod server; +pub mod skills; pub mod store; pub use config::{ diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 8010761f..57d7a78f 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -15,6 +15,7 @@ enum Command { Init(InitOptions), ConfigDefault, ConfigDiff(WorkspacePathOptions), + Skills(SkillsCommand), Help, } @@ -36,6 +37,13 @@ struct WorkspacePathOptions { workspace: PathBuf, } +#[derive(Debug)] +enum SkillsCommand { + List(WorkspacePathOptions), + Lint(WorkspacePathOptions), + Show { workspace: PathBuf, name: String }, +} + #[derive(Debug)] struct CliError(String); @@ -65,6 +73,7 @@ async fn run() -> Result<(), Box> { Command::Init(options) => run_init(options), Command::ConfigDefault => run_config_default(), Command::ConfigDiff(options) => run_config_diff(options), + Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } } @@ -84,6 +93,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Init(parse_init_options(rest)?)) } "config" => parse_config_command(rest), + "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { print_serve_help(); @@ -96,7 +106,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `init`, `config`, or `serve`" + "unknown command `{other}`; expected `init`, `config`, `skills`, or `serve`" ))), } } @@ -123,6 +133,39 @@ fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box Result<(), Box> { + match command { + SkillsCommand::List(options) => { + let catalog = yoi_workspace_server::skills::catalog(&options.workspace); + println!("{}", serde_json::to_string_pretty(&catalog)?); + } + SkillsCommand::Lint(options) => { + let catalog = yoi_workspace_server::skills::lint(&options.workspace); + println!("{}", serde_json::to_string_pretty(&catalog)?); + if catalog + .diagnostics + .iter() + .chain( + catalog + .entries + .iter() + .flat_map(|entry| entry.diagnostics.iter()), + ) + .any(|diagnostic| { + diagnostic.severity == worker::skill::SkillDiagnosticSeverity::Error + }) + { + return Err(Box::new(CliError("Skill lint found errors".to_string()))); + } + } + SkillsCommand::Show { workspace, name } => { + let detail = yoi_workspace_server::skills::detail(&workspace, &name)?; + println!("{}", serde_json::to_string_pretty(&detail)?); + } + } + Ok(()) +} + async fn run_serve(options: ServeOptions) -> Result<(), Box> { let identity = WorkspaceIdentity::load_required(&options.workspace)?; let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?; @@ -187,6 +230,37 @@ fn parse_config_command(args: &[String]) -> Result { } } +fn parse_skills_command(args: &[String]) -> Result { + let Some((subcommand, rest)) = args.split_first() else { + print_skills_help(); + return Ok(Command::Help); + }; + match subcommand.as_str() { + "list" => Ok(Command::Skills(SkillsCommand::List( + parse_workspace_path_options(rest)?, + ))), + "lint" => Ok(Command::Skills(SkillsCommand::Lint( + parse_workspace_path_options(rest)?, + ))), + "show" => { + let Some((name, rest)) = rest.split_first() else { + return Err(CliError("skills show requires a Skill name".to_string())); + }; + Ok(Command::Skills(SkillsCommand::Show { + workspace: parse_workspace_path_options(rest)?.workspace, + name: name.to_string(), + })) + } + "--help" | "-h" => { + print_skills_help(); + Ok(Command::Help) + } + other => Err(CliError(format!( + "unknown skills subcommand `{other}`; expected `list`, `lint`, or `show`" + ))), + } +} + fn parse_workspace_path_options(args: &[String]) -> Result { let mut workspace = std::env::current_dir() .map_err(|error| CliError(format!("failed to read current dir: {error}")))?; @@ -333,7 +407,7 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config [OPTIONS]\n yoi-workspace-server skills [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } @@ -349,6 +423,12 @@ fn print_config_help() { ); } +fn print_skills_help() { + println!( + "yoi-workspace-server skills\n\nUsage:\n yoi-workspace-server skills list [OPTIONS]\n yoi-workspace-server skills lint [OPTIONS]\n yoi-workspace-server skills show [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace Workspace root (defaults to cwd)\n -h, --help Print help" + ); +} + fn print_serve_help() { println!( "yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (legacy dev override)\n --frontend Static SPA build directory to serve (legacy dev override)\n --listen Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help" diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 50719aa8..565d57a1 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -56,6 +56,7 @@ use crate::repositories::{ RepositoryRegistryReader, RepositorySummary, }; use crate::resource_broker::BackendResourceBroker; +use crate::skills; use crate::store::{ ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, @@ -330,6 +331,13 @@ pub fn build_router(api: WorkspaceApi) -> Router { post(scoped_ticket_backend_operation), ) .route("/api/tickets/{id}", get(get_ticket)) + .route("/api/w/{workspace_id}/skills", get(scoped_list_skills)) + .route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills)) + .route("/api/w/{workspace_id}/skills/{name}", get(scoped_get_skill)) + .route( + "/api/w/{workspace_id}/skills/{name}/activate", + get(scoped_activate_skill), + ) .route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket)) .route("/api/objectives", get(list_objectives)) .route( @@ -995,6 +1003,12 @@ struct ScopedRecordPath { id: String, } +#[derive(Debug, Deserialize)] +struct ScopedSkillPath { + workspace_id: String, + name: String, +} + #[derive(Debug, Deserialize)] struct ScopedRepositoryPath { workspace_id: String, @@ -1261,6 +1275,60 @@ async fn scoped_ticket_backend_operation( Ok(Json(response)) } +async fn scoped_list_skills( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(skills::catalog(&api.config.workspace_root))) +} + +async fn scoped_lint_skills( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(skills::lint(&api.config.workspace_root))) +} + +async fn scoped_get_skill( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + skills::detail(&api.config.workspace_root, &path.name) + .map(Json) + .map_err(skill_api_error) +} + +async fn scoped_activate_skill( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + skills::activation(&api.config.workspace_root, &path.name) + .map(Json) + .map_err(skill_api_error) +} + +fn skill_api_error(error: skills::SkillError) -> ApiError { + match error { + skills::SkillError::NotFound(name) => ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: "workspace".to_string(), + code: "skill_not_found".to_string(), + message: format!("unknown Skill `{name}`"), + }, + vec![RuntimeDiagnostic { + code: "skill_not_found".to_string(), + severity: DiagnosticSeverity::Error, + message: format!("unknown Skill `{name}`"), + }], + ), + skills::SkillError::Io(error) => ApiError::from(Error::Io(error)), + } +} + async fn scoped_list_objectives( State(api): State, AxumPath(path): AxumPath, @@ -5096,7 +5164,9 @@ impl IntoResponse for ApiError { | Error::UnknownWorker { .. } | Error::UnknownRepository(_) | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, - Error::Ticket(_) => StatusCode::NOT_FOUND, + Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { + StatusCode::NOT_FOUND + } Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { StatusCode::NOT_FOUND @@ -5846,6 +5916,51 @@ mod tests { ); } + #[tokio::test] + async fn skills_endpoints_use_workspace_backend_catalog_and_progressive_detail() { + let dir = tempfile::tempdir().unwrap(); + let skill_dir = dir.path().join(".yoi/skills/triage-errors"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: triage-errors\ndescription: Use when triaging backend errors and choosing safe diagnostics.\n---\n\n# Triage Errors\n\nInspect logs before changing code.", + ) + .unwrap(); + let api = test_api(dir.path()).await; + + let Json(catalog) = scoped_list_skills( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + ) + .await + .unwrap_or_else(|error| panic!("skill catalog failed: {}", error.error)); + let entry = catalog + .entries + .iter() + .find(|entry| entry.name == "triage-errors") + .expect("workspace Skill catalog entry"); + assert_eq!(entry.provenance.id, "workspace:triage-errors"); + assert!( + !serde_json::to_string(&catalog) + .unwrap() + .contains("# Triage Errors") + ); + + let Json(detail) = scoped_get_skill( + State(api), + AxumPath(ScopedSkillPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + name: "triage-errors".to_string(), + }), + ) + .await + .unwrap_or_else(|error| panic!("skill detail failed: {}", error.error)); + assert!(detail.body.contains("# Triage Errors")); + assert_eq!(detail.provenance.id, "workspace:triage-errors"); + } + async fn test_api(workspace_root: impl Into) -> WorkspaceApi { let store = SqliteWorkspaceStore::in_memory().unwrap(); WorkspaceApi::new_with_execution_backend( diff --git a/crates/workspace-server/src/skills.rs b/crates/workspace-server/src/skills.rs new file mode 100644 index 00000000..af4868ea --- /dev/null +++ b/crates/workspace-server/src/skills.rs @@ -0,0 +1,646 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use worker::skill::{ + SkillActivationResponse, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse, + SkillDiagnostic, SkillDiagnosticSeverity, SkillProvenance, SkillResourceRef, SkillSourceKind, +}; + +const BUILTIN_AGENT_SKILLS: &str = include_str!("../../../resources/skills/agent-skills/SKILL.md"); + +#[derive(Debug, thiserror::Error)] +pub enum SkillError { + #[error("unknown Skill `{0}`")] + NotFound(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug, Clone)] +struct SkillSource { + source_kind: SkillSourceKind, + parent_name: String, + content: String, + resource_root: Option, +} + +#[derive(Debug, Clone)] +struct ParsedSkill { + name: String, + description: String, + content: String, + allowed_tools: Vec, + diagnostics: Vec, + provenance: SkillProvenance, + resource_root: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct SkillFrontmatter { + name: Option, + description: Option, + license: Option, + compatibility: Option, + metadata: Option>, + #[serde(default)] + allowed_tools: Option, +} + +pub fn catalog(workspace_root: &Path) -> SkillCatalogResponse { + let mut diagnostics = Vec::new(); + let mut active = BTreeMap::::new(); + let mut builtin_by_name = BTreeMap::::new(); + + for source in builtin_skill_sources() { + match parse_skill_source(source) { + Ok(skill) => { + builtin_by_name.insert(skill.name.clone(), skill.clone()); + active.insert(skill.name.clone(), skill); + } + Err(errs) => diagnostics.extend(errs), + } + } + + let workspace_sources = match workspace_skill_sources(workspace_root) { + Ok(sources) => sources, + Err(error) => { + diagnostics.push(SkillDiagnostic::error( + "workspace_skill_read_failed", + format!("failed to read workspace Skills: {error}"), + Some("workspace:.yoi/skills".to_string()), + )); + Vec::new() + } + }; + + for source in workspace_sources { + match parse_skill_source(source) { + Ok(skill) => { + let overrides = builtin_by_name + .get(&skill.name) + .map(|builtin| vec![builtin.provenance.clone()]) + .unwrap_or_default(); + if let Some(overridden) = overrides.first() { + diagnostics.push(SkillDiagnostic::warning( + "workspace_skill_overrides_builtin", + format!( + "workspace Skill `{}` overrides builtin Skill `{}`", + skill.name, overridden.id + ), + Some(skill.provenance.id.clone()), + )); + } + let mut skill = skill; + if !overrides.is_empty() { + skill.diagnostics.push(SkillDiagnostic::warning( + "workspace_skill_overrides_builtin", + "workspace Skill has priority over the builtin Skill with the same name", + Some(skill.provenance.id.clone()), + )); + } + active.insert(skill.name.clone(), skill); + } + Err(errs) => diagnostics.extend(errs), + } + } + + let mut entries = active + .into_values() + .map(|skill| { + let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) { + builtin_by_name + .get(&skill.name) + .map(|builtin| vec![builtin.provenance.clone()]) + .unwrap_or_default() + } else { + Vec::new() + }; + SkillCatalogEntry { + name: skill.name, + description: skill.description, + provenance: skill.provenance, + overrides, + diagnostics: skill.diagnostics, + } + }) + .collect::>(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + + SkillCatalogResponse { + authority: "workspace-backend-skills-v0".to_string(), + entries, + diagnostics, + } +} + +pub fn lint(workspace_root: &Path) -> SkillCatalogResponse { + catalog(workspace_root) +} + +pub fn detail(workspace_root: &Path, name: &str) -> Result { + let skill = active_skill(workspace_root, name)?; + let overrides = if matches!(&skill.provenance.kind, SkillSourceKind::Workspace) { + builtin_skill_sources() + .into_iter() + .filter_map(|source| parse_skill_source(source).ok()) + .find(|builtin| builtin.name == skill.name) + .map(|builtin| vec![builtin.provenance]) + .unwrap_or_default() + } else { + Vec::new() + }; + let resources = resource_refs(&skill); + Ok(SkillDetailResponse { + name: skill.name, + description: skill.description, + provenance: skill.provenance, + overrides, + diagnostics: skill.diagnostics, + body: skill.content, + allowed_tools: skill.allowed_tools, + allowed_tools_status: + "experimental_ignored_by_workspace_backend; does not grant or deny tool authority" + .to_string(), + resources, + }) +} + +pub fn activation( + workspace_root: &Path, + name: &str, +) -> Result { + let detail = detail(workspace_root, name)?; + Ok(SkillActivationResponse { + name: detail.name, + provenance: detail.provenance, + diagnostics: detail.diagnostics, + body: detail.body, + }) +} + +fn active_skill(workspace_root: &Path, name: &str) -> Result { + let mut parsed = BTreeMap::::new(); + for source in builtin_skill_sources() { + if let Ok(skill) = parse_skill_source(source) { + parsed.insert(skill.name.clone(), skill); + } + } + for source in workspace_skill_sources(workspace_root)? { + if let Ok(skill) = parse_skill_source(source) { + parsed.insert(skill.name.clone(), skill); + } + } + parsed + .remove(name) + .ok_or_else(|| SkillError::NotFound(name.to_string())) +} + +fn builtin_skill_sources() -> Vec { + vec![SkillSource { + source_kind: SkillSourceKind::Builtin, + parent_name: "agent-skills".to_string(), + content: BUILTIN_AGENT_SKILLS.to_string(), + resource_root: None, + }] +} + +fn workspace_skill_sources(workspace_root: &Path) -> Result, std::io::Error> { + let skills_dir = workspace_root.join(".yoi").join("skills"); + if !skills_dir.exists() { + return Ok(Vec::new()); + } + let mut sources = Vec::new(); + for entry in fs::read_dir(&skills_dir)? { + let entry = entry?; + let file_type = entry.file_type()?; + if !file_type.is_dir() { + continue; + } + let parent_name = entry.file_name().to_string_lossy().to_string(); + let skill_path = entry.path().join("SKILL.md"); + if !skill_path.exists() { + sources.push(SkillSource { + source_kind: SkillSourceKind::Workspace, + parent_name, + content: String::new(), + resource_root: Some(entry.path()), + }); + continue; + } + match fs::read_to_string(&skill_path) { + Ok(content) => sources.push(SkillSource { + source_kind: SkillSourceKind::Workspace, + parent_name, + content, + resource_root: Some(entry.path()), + }), + Err(error) => sources.push(SkillSource { + source_kind: SkillSourceKind::Workspace, + parent_name, + content: format!("__read_error__:{error}"), + resource_root: None, + }), + } + } + Ok(sources) +} + +fn parse_skill_source(source: SkillSource) -> Result> { + let provenance = provenance(source.source_kind.clone(), &source.parent_name); + let mut diagnostics = Vec::new(); + + if !valid_skill_name(&source.parent_name) { + diagnostics.push(SkillDiagnostic::error( + "invalid_skill_directory_name", + "Skill directory name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen", + Some(provenance.id.clone()), + )); + } + if source.content.is_empty() { + diagnostics.push(SkillDiagnostic::error( + "missing_skill_markdown", + "Skill directory must contain SKILL.md", + Some(provenance.id.clone()), + )); + return Err(diagnostics); + } + if source.content.starts_with("__read_error__:") { + diagnostics.push(SkillDiagnostic::error( + "skill_markdown_read_failed", + source + .content + .trim_start_matches("__read_error__:") + .to_string(), + Some(provenance.id.clone()), + )); + return Err(diagnostics); + } + + let Some((frontmatter, _markdown)) = split_frontmatter(&source.content) else { + diagnostics.push(SkillDiagnostic::error( + "missing_frontmatter", + "SKILL.md must start with YAML frontmatter delimited by ---", + Some(provenance.id.clone()), + )); + return Err(diagnostics); + }; + let frontmatter = match serde_yaml::from_str::(frontmatter) { + Ok(frontmatter) => frontmatter, + Err(error) => { + diagnostics.push(SkillDiagnostic::error( + "invalid_frontmatter_yaml", + format!("SKILL.md frontmatter is invalid YAML: {error}"), + Some(provenance.id.clone()), + )); + return Err(diagnostics); + } + }; + + let Some(name) = frontmatter.name else { + diagnostics.push(SkillDiagnostic::error( + "missing_name", + "Skill frontmatter requires `name`", + Some(provenance.id.clone()), + )); + return Err(diagnostics); + }; + if name != source.parent_name { + diagnostics.push(SkillDiagnostic::error( + "name_parent_mismatch", + "Skill frontmatter `name` must match its parent directory name", + Some(provenance.id.clone()), + )); + } + if !valid_skill_name(&name) { + diagnostics.push(SkillDiagnostic::error( + "invalid_name", + "Skill name must be 1-64 chars of lowercase letters, numbers, or single hyphens with no leading/trailing hyphen", + Some(provenance.id.clone()), + )); + } + + let Some(description) = frontmatter.description else { + diagnostics.push(SkillDiagnostic::error( + "missing_description", + "Skill frontmatter requires `description`", + Some(provenance.id.clone()), + )); + return Err(diagnostics); + }; + let description = description.trim().to_string(); + if description.is_empty() || description.chars().count() > 1024 { + diagnostics.push(SkillDiagnostic::error( + "invalid_description", + "Skill description must be 1-1024 characters", + Some(provenance.id.clone()), + )); + } else if description.chars().count() < 16 { + diagnostics.push(SkillDiagnostic::warning( + "description_too_generic", + "Skill description should state concrete when/what guidance", + Some(provenance.id.clone()), + )); + } + + if let Some(license) = frontmatter.license.as_deref() { + validate_optional_string("license", license, &provenance, &mut diagnostics); + } + if let Some(compatibility) = frontmatter.compatibility.as_deref() { + validate_optional_string( + "compatibility", + compatibility, + &provenance, + &mut diagnostics, + ); + } + if let Some(metadata) = frontmatter.metadata { + for (key, value) in metadata { + if !matches!(value, serde_yaml::Value::String(_)) { + diagnostics.push(SkillDiagnostic::error( + "invalid_metadata_value", + format!("metadata `{key}` must be a string value"), + Some(provenance.id.clone()), + )); + } + } + } + + let mut allowed_tools = Vec::new(); + if let Some(value) = frontmatter.allowed_tools { + allowed_tools = parse_allowed_tools(value, &provenance, &mut diagnostics); + diagnostics.push(SkillDiagnostic::warning( + "allowed_tools_ignored", + "allowed-tools is experimental metadata only; Workspace Skill activation does not grant or deny tools", + Some(provenance.id.clone()), + )); + } + + if diagnostics + .iter() + .any(|d| d.severity == SkillDiagnosticSeverity::Error) + { + return Err(diagnostics); + } + + Ok(ParsedSkill { + name, + description, + content: source.content, + allowed_tools, + diagnostics, + provenance, + resource_root: source.resource_root, + }) +} + +fn provenance(kind: SkillSourceKind, parent_name: &str) -> SkillProvenance { + let prefix = match kind { + SkillSourceKind::Builtin => "builtin", + SkillSourceKind::Workspace => "workspace", + }; + SkillProvenance { + kind, + id: format!("{prefix}:{parent_name}"), + } +} + +fn split_frontmatter(content: &str) -> Option<(&str, &str)> { + let rest = content.strip_prefix("---\n")?; + let (frontmatter, body) = rest.split_once("\n---")?; + let body = body.strip_prefix('\n').unwrap_or(body); + Some((frontmatter, body)) +} + +fn valid_skill_name(name: &str) -> bool { + let len = name.chars().count(); + if !(1..=64).contains(&len) + || name.starts_with('-') + || name.ends_with('-') + || name.contains("--") + { + return false; + } + name.chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + +fn validate_optional_string( + field: &str, + value: &str, + provenance: &SkillProvenance, + diagnostics: &mut Vec, +) { + if value.trim().is_empty() { + diagnostics.push(SkillDiagnostic::error( + format!("invalid_{field}"), + format!("optional `{field}` must be a non-empty string when present"), + Some(provenance.id.clone()), + )); + } +} + +fn parse_allowed_tools( + value: serde_yaml::Value, + provenance: &SkillProvenance, + diagnostics: &mut Vec, +) -> Vec { + match value { + serde_yaml::Value::String(text) => text + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect(), + serde_yaml::Value::Sequence(items) => items + .into_iter() + .filter_map(|item| match item { + serde_yaml::Value::String(text) if !text.trim().is_empty() => Some(text), + _ => { + diagnostics.push(SkillDiagnostic::warning( + "invalid_allowed_tools_entry_ignored", + "allowed-tools entries must be strings; invalid entries are ignored", + Some(provenance.id.clone()), + )); + None + } + }) + .collect(), + _ => { + diagnostics.push(SkillDiagnostic::warning( + "invalid_allowed_tools_ignored", + "allowed-tools must be a string or string list; value ignored", + Some(provenance.id.clone()), + )); + Vec::new() + } + } +} + +fn resource_refs(skill: &ParsedSkill) -> Vec { + let Some(root) = &skill.resource_root else { + return Vec::new(); + }; + let mut refs = Vec::new(); + for (dir, kind, supported, diagnostic) in [ + ( + "references", + "reference", + false, + Some( + "Skill references are listed only; resource read endpoints are not implemented yet", + ), + ), + ( + "assets", + "asset", + false, + Some("Skill assets are listed only; resource read endpoints are not implemented yet"), + ), + ( + "scripts", + "script", + false, + Some( + "Skill scripts are discovered but not executable; use normal typed tools and permissions", + ), + ), + ] { + let path = root.join(dir); + if !path.is_dir() { + continue; + } + if let Ok(entries) = fs::read_dir(&path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if !(file_type.is_file() || file_type.is_dir()) { + continue; + } + } + let name = format!("{dir}/{}", entry.file_name().to_string_lossy()); + refs.push(SkillResourceRef { + kind: kind.to_string(), + name, + supported, + diagnostic: diagnostic.map(ToOwned::to_owned), + }); + } + } + } + refs.sort_by(|a, b| a.name.cmp(&b.name)); + refs +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_skill(root: &Path, name: &str, content: &str) { + let dir = root.join(".yoi").join("skills").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("SKILL.md"), content).unwrap(); + } + + #[test] + fn workspace_skill_is_cataloged_without_body_and_detail_contains_body() { + let tmp = tempfile::tempdir().unwrap(); + write_skill( + tmp.path(), + "debug-rust", + "---\nname: debug-rust\ndescription: Use when debugging Rust failures and deciding what tests to run.\nallowed-tools:\n - Bash\nmetadata:\n owner: dev\n---\n\n# Debug Rust\n\nRun focused checks.", + ); + + let catalog = catalog(tmp.path()); + let entry = catalog + .entries + .iter() + .find(|entry| entry.name == "debug-rust") + .expect("workspace skill listed"); + assert_eq!( + entry.description, + "Use when debugging Rust failures and deciding what tests to run." + ); + assert_eq!(entry.provenance.id, "workspace:debug-rust"); + let catalog_json = serde_json::to_string(&catalog).unwrap(); + assert!(!catalog_json.contains("# Debug Rust")); + + let detail = detail(tmp.path(), "debug-rust").unwrap(); + assert!(detail.body.contains("# Debug Rust")); + assert_eq!(detail.allowed_tools, vec!["Bash"]); + assert!( + detail + .allowed_tools_status + .contains("does not grant or deny") + ); + assert!( + detail + .diagnostics + .iter() + .any(|d| d.code == "allowed_tools_ignored") + ); + } + + #[test] + fn invalid_name_and_parent_mismatch_are_lint_errors() { + let tmp = tempfile::tempdir().unwrap(); + write_skill( + tmp.path(), + "Bad--Name", + "---\nname: other\ndescription: Use when checking invalid examples.\n---\n\n# Invalid", + ); + let diagnostics = catalog(tmp.path()).diagnostics; + assert!( + diagnostics + .iter() + .any(|d| d.code == "invalid_skill_directory_name") + ); + assert!(diagnostics.iter().any(|d| d.code == "name_parent_mismatch")); + } + + #[test] + fn workspace_skill_overrides_builtin() { + let tmp = tempfile::tempdir().unwrap(); + write_skill( + tmp.path(), + "agent-skills", + "---\nname: agent-skills\ndescription: Use when testing deterministic workspace override of builtin Skills.\n---\n\n# Workspace Override", + ); + let catalog = catalog(tmp.path()); + let entry = catalog + .entries + .iter() + .find(|entry| entry.name == "agent-skills") + .unwrap(); + assert_eq!(entry.provenance.id, "workspace:agent-skills"); + assert_eq!(entry.overrides[0].id, "builtin:agent-skills"); + let detail = detail(tmp.path(), "agent-skills").unwrap(); + assert!(detail.body.contains("Workspace Override")); + } + + #[test] + fn scripts_are_reported_not_executable_without_raw_paths() { + let tmp = tempfile::tempdir().unwrap(); + write_skill( + tmp.path(), + "scripted-help", + "---\nname: scripted-help\ndescription: Use when checking Skill resource diagnostics safely.\n---\n\n# Scripted", + ); + let script_dir = tmp.path().join(".yoi/skills/scripted-help/scripts"); + std::fs::create_dir_all(&script_dir).unwrap(); + std::fs::write(script_dir.join("run.sh"), "echo no").unwrap(); + let detail = detail(tmp.path(), "scripted-help").unwrap(); + let script = detail + .resources + .iter() + .find(|r| r.name == "scripts/run.sh") + .unwrap(); + assert!(!script.supported); + assert!( + !serde_json::to_string(&detail) + .unwrap() + .contains(tmp.path().to_str().unwrap()) + ); + } +} diff --git a/resources/prompts/common/tool-usage.md b/resources/prompts/common/tool-usage.md index 92ff3982..5a758732 100644 --- a/resources/prompts/common/tool-usage.md +++ b/resources/prompts/common/tool-usage.md @@ -5,6 +5,10 @@ When searching, use grep/glob primitives rather than shell pipelines. You can run multiple tools simultaneously by calling them within a single response. It is recommended to run tools that handle asynchronous processing, such as queries and readings, in batches. + +### Agent Skills + +When an Agent Skill is explicitly activated, follow its committed `SKILL.md` body as LLM-facing procedural guidance only. A Skill does not grant authority to mutate Tickets, repositories, networks, worktrees, queues, schedules, scripts, or other external state; use the normal typed tools, features, and permissions for those actions. Skill catalog metadata is lightweight, and full Skill bodies should enter context only through explicit activation/read. {% if tool_capabilities.memory_any %} ### Memory diff --git a/resources/skills/agent-skills/SKILL.md b/resources/skills/agent-skills/SKILL.md new file mode 100644 index 00000000..de39774e --- /dev/null +++ b/resources/skills/agent-skills/SKILL.md @@ -0,0 +1,16 @@ +--- +name: agent-skills +description: Use when a user or host explicitly activates an Agent Skill so the model follows the selected procedural guidance without treating it as external authority. +license: MIT +compatibility: yoi-agent-skills-v0 +metadata: + origin: builtin +--- + +# Agent Skills + +When this Skill is activated, treat the loaded `SKILL.md` as procedural guidance for the current conversation. A Skill can explain how to approach a task, which references to inspect through normal tools, or what style to use. + +A Skill is not authority to mutate Tickets, repositories, networks, worktrees, processes, queues, schedules, or other external state. Use the normal typed tools and feature permissions for any external action. + +`allowed-tools` is experimental metadata only in this implementation. It does not grant or deny tools unless the host feature/permission layer implements that separately. diff --git a/web/workspace/src/lib/workspace/api/http.test.ts b/web/workspace/src/lib/workspace/api/http.test.ts index 77a5a2c5..cdce78a9 100644 --- a/web/workspace/src/lib/workspace/api/http.test.ts +++ b/web/workspace/src/lib/workspace/api/http.test.ts @@ -1,4 +1,11 @@ -import { workspaceApiPath, workspaceRoute } from "./http.ts"; +import { + loadWorkspaceSkillCatalog, + workspaceApiPath, + workspaceRoute, + workspaceSkillActivationPath, + workspaceSkillCatalogPath, + workspaceSkillDetailPath, +} from "./http.ts"; declare const Deno: { test(name: string, fn: () => Promise | void): void; @@ -21,7 +28,10 @@ function assert(condition: unknown, message: string): asserts condition { Deno.test("workspace route helpers scope browser routes and API by immutable workspace id", () => { assertEquals(workspaceRoute("workspace 1"), "/w/workspace%201"); - assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives"); + assertEquals( + workspaceRoute("workspace 1", "/objectives"), + "/w/workspace%201/objectives", + ); assertEquals( workspaceApiPath("workspace 1", "/repositories/repo-a"), "/api/w/workspace%201/repositories/repo-a", @@ -29,7 +39,9 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor }); Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => { - const layout = await Deno.readTextFile(new URL("./../../../routes/+layout.ts", import.meta.url)); + const layout = await Deno.readTextFile( + new URL("./../../../routes/+layout.ts", import.meta.url), + ); assert( layout.includes('loadJson(fetch, "/api/workspace")'), "unscoped layout may use only the workspace-id bootstrap endpoint", @@ -39,7 +51,8 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped "unscoped layout should redirect to the scoped workspace route", ); assert( - !layout.includes('`/api${path}`') && !layout.includes('"/api/repositories"'), + !layout.includes("`/api${path}`") && + !layout.includes('"/api/repositories"'), "layout must not fall back to unscoped workspace-scoped API calls", ); @@ -53,3 +66,44 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped "unscoped settings route should remain a thin redirect shim, not a data/control surface", ); }); + +Deno.test("Skill API paths use workspace backend scoped endpoints", () => { + assertEquals(workspaceSkillCatalogPath("ws 1"), "/api/w/ws%201/skills"); + assertEquals( + workspaceSkillDetailPath("ws 1", "triage-errors"), + "/api/w/ws%201/skills/triage-errors", + ); + assertEquals( + workspaceSkillActivationPath("ws 1", "triage-errors"), + "/api/w/ws%201/skills/triage-errors/activate", + ); +}); + +Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => { + const result = await loadWorkspaceSkillCatalog( + ((input: RequestInfo | URL) => { + assertEquals(String(input), "/api/w/ws-1/skills"); + return Promise.resolve( + new Response( + JSON.stringify({ + authority: "workspace-backend-skills-v0", + entries: [{ + name: "triage-errors", + description: "Use when triaging errors.", + provenance: { kind: "workspace", id: "workspace:triage-errors" }, + overrides: [], + diagnostics: [], + }], + diagnostics: [], + }), + { status: 200 }, + ), + ); + }) as typeof fetch, + "ws-1", + ); + + assertEquals(result.error, null); + assertEquals(result.data?.entries[0].name, "triage-errors"); + assertEquals(JSON.stringify(result.data).includes("SKILL.md body"), false); +}); diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index b519c8c2..39edb54f 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -3,19 +3,108 @@ export type ApiResult = { error: string | null; }; +export type SkillDiagnosticSeverity = "error" | "warning"; + +export type SkillDiagnostic = { + severity: SkillDiagnosticSeverity; + code: string; + message: string; + source?: string; +}; + +export type SkillProvenance = { + kind: "builtin" | "workspace"; + id: string; +}; + +export type SkillCatalogEntry = { + name: string; + description: string; + provenance: SkillProvenance; + overrides: SkillProvenance[]; + diagnostics: SkillDiagnostic[]; +}; + +export type SkillCatalogResponse = { + authority: string; + entries: SkillCatalogEntry[]; + diagnostics: SkillDiagnostic[]; +}; + +export type SkillResourceRef = { + kind: string; + name: string; + supported: boolean; + diagnostic?: string; +}; + +export type SkillDetailResponse = { + name: string; + description: string; + provenance: SkillProvenance; + overrides: SkillProvenance[]; + diagnostics: SkillDiagnostic[]; + body: string; + allowed_tools: string[]; + allowed_tools_status: string; + resources: SkillResourceRef[]; +}; + function normalizePath(path: string): string { - if (!path || path === '/') return ''; - return path.startsWith('/') ? path : `/${path}`; + if (!path || path === "/") return ""; + return path.startsWith("/") ? path : `/${path}`; } -export function workspaceRoute(workspaceId: string, path = ''): string { +export function workspaceRoute(workspaceId: string, path = ""): string { return `/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`; } -export function workspaceApiPath(workspaceId: string, path = ''): string { +export function workspaceApiPath(workspaceId: string, path = ""): string { return `/api/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`; } +export function workspaceSkillCatalogPath(workspaceId: string): string { + return workspaceApiPath(workspaceId, "/skills"); +} + +export function workspaceSkillDetailPath( + workspaceId: string, + name: string, +): string { + return workspaceApiPath(workspaceId, `/skills/${encodeURIComponent(name)}`); +} + +export function workspaceSkillActivationPath( + workspaceId: string, + name: string, +): string { + return workspaceApiPath( + workspaceId, + `/skills/${encodeURIComponent(name)}/activate`, + ); +} + +export async function loadWorkspaceSkillCatalog( + fetchFn: typeof fetch, + workspaceId: string, +): Promise> { + return loadJson( + fetchFn, + workspaceSkillCatalogPath(workspaceId), + ); +} + +export async function loadWorkspaceSkillDetail( + fetchFn: typeof fetch, + workspaceId: string, + name: string, +): Promise> { + return loadJson( + fetchFn, + workspaceSkillDetailPath(workspaceId, name), + ); +} + export async function loadJson( fetchFn: typeof fetch, path: string, @@ -50,7 +139,10 @@ export async function workspaceApiJson(path: string): Promise { return requireJson(await fetch(path), path); } -export async function workspaceApiJsonWithBody(path: string, init: RequestInit): Promise { +export async function workspaceApiJsonWithBody( + path: string, + init: RequestInit, +): Promise { return requireJson( await fetch(path, { headers: {