feat: add workspace-backed agent skills

This commit is contained in:
Keisuke Hirata 2026-07-16 08:39:57 +09:00
parent 05c50e32cf
commit 62ef89a163
No known key found for this signature in database
13 changed files with 1295 additions and 12 deletions

View File

@ -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",

View File

@ -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);

View File

@ -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;

238
crates/worker/src/skill.rs Normal file
View File

@ -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<String>,
}
impl SkillDiagnostic {
pub fn error(
code: impl Into<String>,
message: impl Into<String>,
source: Option<String>,
) -> Self {
Self {
severity: SkillDiagnosticSeverity::Error,
code: code.into(),
message: message.into(),
source,
}
}
pub fn warning(
code: impl Into<String>,
message: impl Into<String>,
source: Option<String>,
) -> 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:<name>` or `workspace:<name>`.
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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillCatalogEntry {
pub name: String,
pub description: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub overrides: Vec<SkillProvenance>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
}
#[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<SkillCatalogEntry>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillDetailResponse {
pub name: String,
pub description: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub overrides: Vec<SkillProvenance>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// Full SKILL.md contents. This is intentionally omitted from catalog responses.
pub body: String,
#[serde(default)]
pub allowed_tools: Vec<String>,
/// Explicitly documents that allowed-tools is parsed only as an experimental hint.
pub allowed_tools_status: String,
#[serde(default)]
pub resources: Vec<SkillResourceRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillActivationResponse {
pub name: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// 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<SkillCatalogResponse, SkillClientError> {
self.get_skill_json("skills")
}
pub fn read_skill(&self, name: &str) -> Result<SkillDetailResponse, SkillClientError> {
self.get_skill_json(&format!("skills/{name}"))
}
pub fn activate_skill(&self, name: &str) -> Result<SkillActivationResponse, SkillClientError> {
self.get_skill_json(&format!("skills/{name}/activate"))
}
fn get_skill_json<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
) -> Result<T, SkillClientError> {
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();
}
}

View File

@ -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<C: LlmClient, St: Store> Worker<C, St> {
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<SkillActivationResponse, WorkerError> {
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),

View File

@ -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::{

View File

@ -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<dyn std::error::Error>> {
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<Command, CliError> {
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<Command, CliError> {
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<dyn std::err
Ok(())
}
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Command, CliError> {
}
}
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
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<WorkspacePathOptions, CliError> {
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<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [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 <COMMAND> [OPTIONS]\n yoi-workspace-server skills <COMMAND> [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 <NAME> [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 <PATH> 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 <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (legacy dev override)\n --frontend <PATH> Static SPA build directory to serve (legacy dev override)\n --listen <ADDR> Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help"

View File

@ -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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(skills::catalog(&api.config.workspace_root)))
}
async fn scoped_lint_skills(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(skills::lint(&api.config.workspace_root)))
}
async fn scoped_get_skill(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedSkillPath>,
) -> ApiResult<Json<worker::skill::SkillDetailResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedSkillPath>,
) -> ApiResult<Json<worker::skill::SkillActivationResponse>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@ -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<PathBuf>) -> WorkspaceApi {
let store = SqliteWorkspaceStore::in_memory().unwrap();
WorkspaceApi::new_with_execution_backend(

View File

@ -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<PathBuf>,
}
#[derive(Debug, Clone)]
struct ParsedSkill {
name: String,
description: String,
content: String,
allowed_tools: Vec<String>,
diagnostics: Vec<SkillDiagnostic>,
provenance: SkillProvenance,
resource_root: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct SkillFrontmatter {
name: Option<String>,
description: Option<String>,
license: Option<String>,
compatibility: Option<String>,
metadata: Option<BTreeMap<String, serde_yaml::Value>>,
#[serde(default)]
allowed_tools: Option<serde_yaml::Value>,
}
pub fn catalog(workspace_root: &Path) -> SkillCatalogResponse {
let mut diagnostics = Vec::new();
let mut active = BTreeMap::<String, ParsedSkill>::new();
let mut builtin_by_name = BTreeMap::<String, ParsedSkill>::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::<Vec<_>>();
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<SkillDetailResponse, SkillError> {
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<SkillActivationResponse, SkillError> {
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<ParsedSkill, SkillError> {
let mut parsed = BTreeMap::<String, ParsedSkill>::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<SkillSource> {
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<Vec<SkillSource>, 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<ParsedSkill, Vec<SkillDiagnostic>> {
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::<SkillFrontmatter>(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<SkillDiagnostic>,
) {
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<SkillDiagnostic>,
) -> Vec<String> {
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<SkillResourceRef> {
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())
);
}
}

View File

@ -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

View File

@ -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.

View File

@ -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): 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<WorkspaceResponse>(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);
});

View File

@ -3,19 +3,108 @@ export type ApiResult<T> = {
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<ApiResult<SkillCatalogResponse>> {
return loadJson<SkillCatalogResponse>(
fetchFn,
workspaceSkillCatalogPath(workspaceId),
);
}
export async function loadWorkspaceSkillDetail(
fetchFn: typeof fetch,
workspaceId: string,
name: string,
): Promise<ApiResult<SkillDetailResponse>> {
return loadJson<SkillDetailResponse>(
fetchFn,
workspaceSkillDetailPath(workspaceId, name),
);
}
export async function loadJson<T>(
fetchFn: typeof fetch,
path: string,
@ -50,7 +139,10 @@ export async function workspaceApiJson<T>(path: string): Promise<T> {
return requireJson<T>(await fetch(path), path);
}
export async function workspaceApiJsonWithBody<T>(path: string, init: RequestInit): Promise<T> {
export async function workspaceApiJsonWithBody<T>(
path: string,
init: RequestInit,
): Promise<T> {
return requireJson<T>(
await fetch(path, {
headers: {