merge: agent skills support
This commit is contained in:
commit
1611e04d17
|
|
@ -139,6 +139,10 @@ pub enum SystemItem {
|
||||||
/// byte-identical to what was sent.
|
/// byte-identical to what was sent.
|
||||||
FileAttachment { path: String, body: String },
|
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
|
/// Historical persisted Knowledge reference resolution. Knowledge is no
|
||||||
/// longer active, so restored sessions ignore this item instead of replaying
|
/// longer active, so restored sessions ignore this item instead of replaying
|
||||||
/// archived record text into model context.
|
/// archived record text into model context.
|
||||||
|
|
@ -174,6 +178,7 @@ impl SystemItem {
|
||||||
SystemItem::Notification { body, .. } => body.clone(),
|
SystemItem::Notification { body, .. } => body.clone(),
|
||||||
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
||||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||||
|
SystemItem::SkillActivation { body, .. } => body.clone(),
|
||||||
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
|
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
|
||||||
SystemItem::LegacyIgnored { slug } => {
|
SystemItem::LegacyIgnored { slug } => {
|
||||||
format!("Ignored legacy procedure item: /{slug}")
|
format!("Ignored legacy procedure item: /{slug}")
|
||||||
|
|
@ -196,6 +201,7 @@ impl SystemItem {
|
||||||
SystemItem::Notification { .. } => "notification",
|
SystemItem::Notification { .. } => "notification",
|
||||||
SystemItem::WorkerEvent { .. } => "worker_event",
|
SystemItem::WorkerEvent { .. } => "worker_event",
|
||||||
SystemItem::FileAttachment { .. } => "file_attachment",
|
SystemItem::FileAttachment { .. } => "file_attachment",
|
||||||
|
SystemItem::SkillActivation { .. } => "skill_activation",
|
||||||
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
||||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||||
|
|
|
||||||
|
|
@ -2163,6 +2163,7 @@ impl App {
|
||||||
self.blocks.push(Block::WorkerEvent { event });
|
self.blocks.push(Block::WorkerEvent { event });
|
||||||
}
|
}
|
||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
|
| session_store::SystemItem::SkillActivation { body, .. }
|
||||||
| session_store::SystemItem::TaskReminder { body, .. }
|
| session_store::SystemItem::TaskReminder { body, .. }
|
||||||
| session_store::SystemItem::Interrupt { body } => {
|
| session_store::SystemItem::Interrupt { body } => {
|
||||||
self.task_store.apply_system_message_text(&body);
|
self.task_store.apply_system_message_text(&body);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ pub mod runtime;
|
||||||
pub mod segment_log_sink;
|
pub mod segment_log_sink;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
mod shutdown_after_idle;
|
mod shutdown_after_idle;
|
||||||
|
pub mod skill;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
|
|
||||||
mod interrupt_prep;
|
mod interrupt_prep;
|
||||||
|
|
|
||||||
238
crates/worker/src/skill.rs
Normal file
238
crates/worker/src/skill.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ use crate::prompt::loader::PromptLoader;
|
||||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::runtime::dir;
|
use crate::runtime::dir;
|
||||||
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
|
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
|
||||||
|
use crate::skill::{SkillActivationResponse, SkillClientError};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use protocol::{
|
use protocol::{
|
||||||
|
|
@ -896,6 +897,31 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||||
self.workspace_context.client()
|
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
|
pub(crate) fn worker_metadata_store(&self) -> St
|
||||||
where
|
where
|
||||||
St: Clone,
|
St: Clone,
|
||||||
|
|
@ -4789,6 +4815,9 @@ pub enum WorkerError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
PromptCatalog(#[from] CatalogError),
|
PromptCatalog(#[from] CatalogError),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Skill(#[from] SkillClientError),
|
||||||
|
|
||||||
#[error("memory extract staging write failed: {0}")]
|
#[error("memory extract staging write failed: {0}")]
|
||||||
ExtractStaging(#[source] memory::extract::StagingError),
|
ExtractStaging(#[source] memory::extract::StagingError),
|
||||||
|
|
||||||
|
|
@ -5939,6 +5968,102 @@ mod build_summary_prompt_tests {
|
||||||
assert!(!prompt.contains("resident summary marker"));
|
assert!(!prompt.contains("resident summary marker"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activate_skill_commits_and_appends_history_before_future_context_use() {
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let server = 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-skill/skills/triage-errors/activate 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!({
|
||||||
|
"name": "triage-errors",
|
||||||
|
"provenance": { "kind": "workspace", "id": "workspace:triage-errors" },
|
||||||
|
"diagnostics": [],
|
||||||
|
"body": "---\nname: triage-errors\ndescription: Use when testing activation history.\n---\n\n# Triage Errors\n\nCommitted Skill body."
|
||||||
|
})
|
||||||
|
.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 dir = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = minimal_manifest();
|
||||||
|
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||||
|
let cwd = dir.path().join("workspace");
|
||||||
|
std::fs::create_dir_all(&cwd).unwrap();
|
||||||
|
let scope = Scope::writable(&cwd).unwrap();
|
||||||
|
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||||
|
let mut worker = tokio::runtime::Runtime::new()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(Worker::new(
|
||||||
|
manifest,
|
||||||
|
Engine::new(NoopClient),
|
||||||
|
store,
|
||||||
|
WorkerWorkspaceContext::with_client(
|
||||||
|
Some(WorkspaceId::new("ws-skill").unwrap()),
|
||||||
|
WorkspaceClient::http("ws-skill", format!("http://{addr}")),
|
||||||
|
),
|
||||||
|
authority,
|
||||||
|
scope,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let activation = worker.activate_skill("triage-errors").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(activation.name, "triage-errors");
|
||||||
|
server.join().unwrap();
|
||||||
|
let history = worker.history();
|
||||||
|
assert_eq!(history.len(), 1);
|
||||||
|
let history_text = history[0].as_text().unwrap();
|
||||||
|
assert!(
|
||||||
|
history_text
|
||||||
|
.contains("Agent Skill `triage-errors` activated from workspace:triage-errors")
|
||||||
|
);
|
||||||
|
assert!(history_text.contains("# Triage Errors"));
|
||||||
|
assert!(history_text.contains("Committed Skill body."));
|
||||||
|
|
||||||
|
let entries = worker
|
||||||
|
.store
|
||||||
|
.read_all(
|
||||||
|
worker.segment_state.session_id(),
|
||||||
|
worker.segment_state.segment_id(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(entries.iter().any(|entry| {
|
||||||
|
matches!(
|
||||||
|
entry,
|
||||||
|
LogEntry::SystemItem {
|
||||||
|
item: SystemItem::SkillActivation { name, body },
|
||||||
|
..
|
||||||
|
} if name == "triage-errors"
|
||||||
|
&& body.contains("# Triage Errors")
|
||||||
|
&& body == history_text
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
fn minimal_manifest() -> WorkerManifest {
|
fn minimal_manifest() -> WorkerManifest {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
[worker]
|
[worker]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub mod records;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod resource_broker;
|
pub mod resource_broker;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod skills;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
pub use config::{
|
pub use config::{
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ enum Command {
|
||||||
Init(InitOptions),
|
Init(InitOptions),
|
||||||
ConfigDefault,
|
ConfigDefault,
|
||||||
ConfigDiff(WorkspacePathOptions),
|
ConfigDiff(WorkspacePathOptions),
|
||||||
|
Skills(SkillsCommand),
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +37,13 @@ struct WorkspacePathOptions {
|
||||||
workspace: PathBuf,
|
workspace: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum SkillsCommand {
|
||||||
|
List(WorkspacePathOptions),
|
||||||
|
Lint(WorkspacePathOptions),
|
||||||
|
Show { workspace: PathBuf, name: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct CliError(String);
|
struct CliError(String);
|
||||||
|
|
||||||
|
|
@ -65,6 +73,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Command::Init(options) => run_init(options),
|
Command::Init(options) => run_init(options),
|
||||||
Command::ConfigDefault => run_config_default(),
|
Command::ConfigDefault => run_config_default(),
|
||||||
Command::ConfigDiff(options) => run_config_diff(options),
|
Command::ConfigDiff(options) => run_config_diff(options),
|
||||||
|
Command::Skills(command) => run_skills(command),
|
||||||
Command::Help => Ok(()),
|
Command::Help => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +93,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
Ok(Command::Init(parse_init_options(rest)?))
|
Ok(Command::Init(parse_init_options(rest)?))
|
||||||
}
|
}
|
||||||
"config" => parse_config_command(rest),
|
"config" => parse_config_command(rest),
|
||||||
|
"skills" => parse_skills_command(rest),
|
||||||
"serve" => {
|
"serve" => {
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
print_serve_help();
|
print_serve_help();
|
||||||
|
|
@ -96,7 +106,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
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(())
|
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>> {
|
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
|
let identity = WorkspaceIdentity::load_required(&options.workspace)?;
|
||||||
let config_file = WorkspaceBackendConfigFile::load_for_workspace(&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> {
|
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
||||||
let mut workspace = std::env::current_dir()
|
let mut workspace = std::env::current_dir()
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
.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() {
|
fn print_help() {
|
||||||
println!(
|
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() {
|
fn print_serve_help() {
|
||||||
println!(
|
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"
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ use crate::repositories::{
|
||||||
RepositoryRegistryReader, RepositorySummary,
|
RepositoryRegistryReader, RepositorySummary,
|
||||||
};
|
};
|
||||||
use crate::resource_broker::BackendResourceBroker;
|
use crate::resource_broker::BackendResourceBroker;
|
||||||
|
use crate::skills;
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
||||||
WorkspaceRecord,
|
WorkspaceRecord,
|
||||||
|
|
@ -330,6 +331,13 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
post(scoped_ticket_backend_operation),
|
post(scoped_ticket_backend_operation),
|
||||||
)
|
)
|
||||||
.route("/api/tickets/{id}", get(get_ticket))
|
.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/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket))
|
||||||
.route("/api/objectives", get(list_objectives))
|
.route("/api/objectives", get(list_objectives))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -995,6 +1003,12 @@ struct ScopedRecordPath {
|
||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ScopedSkillPath {
|
||||||
|
workspace_id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ScopedRepositoryPath {
|
struct ScopedRepositoryPath {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
|
|
@ -1261,6 +1275,60 @@ async fn scoped_ticket_backend_operation(
|
||||||
Ok(Json(response))
|
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(
|
async fn scoped_list_objectives(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
|
@ -5096,7 +5164,9 @@ impl IntoResponse for ApiError {
|
||||||
| Error::UnknownWorker { .. }
|
| Error::UnknownWorker { .. }
|
||||||
| Error::UnknownRepository(_)
|
| Error::UnknownRepository(_)
|
||||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
| 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::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||||
StatusCode::NOT_FOUND
|
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 {
|
async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
WorkspaceApi::new_with_execution_backend(
|
WorkspaceApi::new_with_execution_backend(
|
||||||
|
|
|
||||||
789
crates/workspace-server/src/skills.rs
Normal file
789
crates/workspace-server/src/skills.rs
Normal file
|
|
@ -0,0 +1,789 @@
|
||||||
|
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_value = match serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_frontmatter_yaml",
|
||||||
|
format!("SKILL.md frontmatter is invalid YAML: {error}"),
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return Err(diagnostics);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
diagnose_unsupported_frontmatter_keys(&frontmatter_value, &provenance, &mut diagnostics);
|
||||||
|
let frontmatter = match serde_yaml::from_value::<SkillFrontmatter>(frontmatter_value) {
|
||||||
|
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 diagnose_unsupported_frontmatter_keys(
|
||||||
|
value: &serde_yaml::Value,
|
||||||
|
provenance: &SkillProvenance,
|
||||||
|
diagnostics: &mut Vec<SkillDiagnostic>,
|
||||||
|
) {
|
||||||
|
let Some(mapping) = value.as_mapping() else {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_frontmatter_shape",
|
||||||
|
"SKILL.md frontmatter must be a YAML mapping",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for key in mapping.keys() {
|
||||||
|
let Some(key) = key.as_str() else {
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
"invalid_frontmatter_key",
|
||||||
|
"Skill frontmatter keys must be strings",
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_supported_frontmatter_key(key) {
|
||||||
|
let (code, message) = if is_workflow_projection_key(key) {
|
||||||
|
(
|
||||||
|
"unsupported_workflow_frontmatter_field",
|
||||||
|
format!(
|
||||||
|
"Skill frontmatter field `{key}` is a removed Workflow projection/invocation field and is not accepted as Skill semantics"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"unsupported_frontmatter_field",
|
||||||
|
format!(
|
||||||
|
"Skill frontmatter field `{key}` is not supported; supported fields are name, description, license, compatibility, metadata, and allowed-tools"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
diagnostics.push(SkillDiagnostic::error(
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
Some(provenance.id.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_frontmatter_key(key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
key,
|
||||||
|
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_workflow_projection_key(key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
key,
|
||||||
|
"model_invokation"
|
||||||
|
| "model_invocation"
|
||||||
|
| "user_invocable"
|
||||||
|
| "workflow"
|
||||||
|
| "workflow_record"
|
||||||
|
| "workflow_invoke"
|
||||||
|
| "invocation"
|
||||||
|
| "invocations"
|
||||||
|
| "graph"
|
||||||
|
| "nodes"
|
||||||
|
| "edges"
|
||||||
|
| "triggers"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 workflow_projection_frontmatter_fields_are_rejected() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"workflow-shaped",
|
||||||
|
"---\nname: workflow-shaped\ndescription: Use when proving workflow projection fields are rejected as Skills.\nmodel_invokation: old-typo\nuser_invocable: true\ngraph: {}\ninvocation:\n run: now\n---\n\n# Workflow Shaped",
|
||||||
|
);
|
||||||
|
|
||||||
|
let catalog = catalog(tmp.path());
|
||||||
|
assert!(
|
||||||
|
catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.all(|entry| entry.name != "workflow-shaped")
|
||||||
|
);
|
||||||
|
let codes = catalog
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| diagnostic.code.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(codes.contains(&"unsupported_workflow_frontmatter_field"));
|
||||||
|
for unsupported in ["model_invokation", "user_invocable", "graph", "invocation"] {
|
||||||
|
assert!(
|
||||||
|
catalog.diagnostics.iter().any(|diagnostic| {
|
||||||
|
diagnostic.code == "unsupported_workflow_frontmatter_field"
|
||||||
|
&& diagnostic.message.contains(unsupported)
|
||||||
|
}),
|
||||||
|
"missing unsupported-field diagnostic for {unsupported}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
detail(tmp.path(), "workflow-shaped"),
|
||||||
|
Err(SkillError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_frontmatter_fields_are_rejected() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_skill(
|
||||||
|
tmp.path(),
|
||||||
|
"unknown-field",
|
||||||
|
"---\nname: unknown-field\ndescription: Use when proving unsupported Skill fields are rejected.\ncustom-authority: no\n---\n\n# Unknown",
|
||||||
|
);
|
||||||
|
|
||||||
|
let catalog = catalog(tmp.path());
|
||||||
|
assert!(
|
||||||
|
catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.all(|entry| entry.name != "unknown-field")
|
||||||
|
);
|
||||||
|
assert!(catalog.diagnostics.iter().any(|diagnostic| diagnostic.code
|
||||||
|
== "unsupported_frontmatter_field"
|
||||||
|
&& diagnostic.message.contains("custom-authority")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
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.
|
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 %}
|
{% if tool_capabilities.memory_any %}
|
||||||
|
|
||||||
### Memory
|
### Memory
|
||||||
|
|
|
||||||
16
resources/skills/agent-skills/SKILL.md
Normal file
16
resources/skills/agent-skills/SKILL.md
Normal 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.
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import { workspaceApiPath, workspaceRoute } from "./http.ts";
|
import {
|
||||||
|
loadWorkspaceSkillCatalog,
|
||||||
|
workspaceApiPath,
|
||||||
|
workspaceRoute,
|
||||||
|
workspaceSkillActivationPath,
|
||||||
|
workspaceSkillCatalogPath,
|
||||||
|
workspaceSkillDetailPath,
|
||||||
|
} from "./http.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => Promise<void> | void): void;
|
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", () => {
|
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"), "/w/workspace%201");
|
||||||
assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives");
|
assertEquals(
|
||||||
|
workspaceRoute("workspace 1", "/objectives"),
|
||||||
|
"/w/workspace%201/objectives",
|
||||||
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
workspaceApiPath("workspace 1", "/repositories/repo-a"),
|
workspaceApiPath("workspace 1", "/repositories/repo-a"),
|
||||||
"/api/w/workspace%201/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 () => {
|
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(
|
assert(
|
||||||
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
||||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
"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",
|
"unscoped layout should redirect to the scoped workspace route",
|
||||||
);
|
);
|
||||||
assert(
|
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",
|
"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",
|
"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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,108 @@ export type ApiResult<T> = {
|
||||||
error: string | null;
|
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 {
|
function normalizePath(path: string): string {
|
||||||
if (!path || path === '/') return '';
|
if (!path || path === "/") return "";
|
||||||
return path.startsWith('/') ? path : `/${path}`;
|
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)}`;
|
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)}`;
|
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>(
|
export async function loadJson<T>(
|
||||||
fetchFn: typeof fetch,
|
fetchFn: typeof fetch,
|
||||||
path: string,
|
path: string,
|
||||||
|
|
@ -50,7 +139,10 @@ export async function workspaceApiJson<T>(path: string): Promise<T> {
|
||||||
return requireJson<T>(await fetch(path), path);
|
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>(
|
return requireJson<T>(
|
||||||
await fetch(path, {
|
await fetch(path, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user