mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Manage skills as sources over ACP (#8675)
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Generated
+1
@@ -4358,6 +4358,7 @@ dependencies = [
|
||||
"fs-err",
|
||||
"fs2",
|
||||
"futures",
|
||||
"goose-sdk",
|
||||
"goose-test-support",
|
||||
"http 1.4.0",
|
||||
"ignore",
|
||||
|
||||
@@ -110,6 +110,36 @@
|
||||
"requestType": "UnarchiveSessionRequest",
|
||||
"responseType": "EmptyResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/sources/create",
|
||||
"requestType": "CreateSourceRequest",
|
||||
"responseType": "CreateSourceResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/sources/list",
|
||||
"requestType": "ListSourcesRequest",
|
||||
"responseType": "ListSourcesResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/sources/update",
|
||||
"requestType": "UpdateSourceRequest",
|
||||
"responseType": "UpdateSourceResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/sources/delete",
|
||||
"requestType": "DeleteSourceRequest",
|
||||
"responseType": "EmptyResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/sources/export",
|
||||
"requestType": "ExportSourceRequest",
|
||||
"responseType": "ExportSourceResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/sources/import",
|
||||
"requestType": "ImportSourcesRequest",
|
||||
"responseType": "ImportSourcesResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/dictation/transcribe",
|
||||
"requestType": "DictationTranscribeRequest",
|
||||
|
||||
@@ -795,6 +795,299 @@
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/session/unarchive"
|
||||
},
|
||||
"CreateSourceRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/$defs/SourceType"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"projectDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Absolute path to the project root. Required when `global` is false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"name",
|
||||
"description",
|
||||
"content",
|
||||
"global"
|
||||
],
|
||||
"description": "Create a new source (global or project-scoped).",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/create"
|
||||
},
|
||||
"SourceType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"skill"
|
||||
],
|
||||
"description": "The type of source entity."
|
||||
},
|
||||
"CreateSourceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"$ref": "#/$defs/SourceEntry"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/create"
|
||||
},
|
||||
"SourceEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/$defs/SourceType"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the source's directory on disk."
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean",
|
||||
"description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"name",
|
||||
"description",
|
||||
"content",
|
||||
"directory",
|
||||
"global"
|
||||
],
|
||||
"description": "A source — a user-editable entity backed by an on-disk directory. Sources\nmay be either `global` (shared across all projects) or project-specific."
|
||||
},
|
||||
"ListSourcesRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/SourceType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"projectDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "List sources. If `type` is omitted, sources of all known types are returned.\nBoth global and project-scoped sources are included when `project_dir` is set.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/list"
|
||||
},
|
||||
"ListSourcesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SourceEntry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sources"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/list"
|
||||
},
|
||||
"UpdateSourceRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/$defs/SourceType"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"projectDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"name",
|
||||
"description",
|
||||
"content",
|
||||
"global"
|
||||
],
|
||||
"description": "Update an existing source's description and content.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/update"
|
||||
},
|
||||
"UpdateSourceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"$ref": "#/$defs/SourceEntry"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/update"
|
||||
},
|
||||
"DeleteSourceRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/$defs/SourceType"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"projectDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"name",
|
||||
"global"
|
||||
],
|
||||
"description": "Delete a source and its on-disk directory.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/delete"
|
||||
},
|
||||
"ExportSourceRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/$defs/SourceType"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"projectDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"name",
|
||||
"global"
|
||||
],
|
||||
"description": "Export a source as a portable JSON payload.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/export"
|
||||
},
|
||||
"ExportSourceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"json": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"json",
|
||||
"filename"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/export"
|
||||
},
|
||||
"ImportSourcesRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"projectDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"global"
|
||||
],
|
||||
"description": "Import a source from a JSON export payload produced by `_goose/sources/export`.\nThe imported source is written under the given scope; on name collisions a\n`-imported` suffix is appended.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/import"
|
||||
},
|
||||
"ImportSourcesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SourceEntry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sources"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/sources/import"
|
||||
},
|
||||
"DictationTranscribeRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1328,6 +1621,60 @@
|
||||
"description": "Params for _goose/session/unarchive",
|
||||
"title": "UnarchiveSessionRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CreateSourceRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/sources/create",
|
||||
"title": "CreateSourceRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ListSourcesRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/sources/list",
|
||||
"title": "ListSourcesRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/UpdateSourceRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/sources/update",
|
||||
"title": "UpdateSourceRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/DeleteSourceRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/sources/delete",
|
||||
"title": "DeleteSourceRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ExportSourceRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/sources/export",
|
||||
"title": "ExportSourceRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ImportSourcesRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/sources/import",
|
||||
"title": "ImportSourcesRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1534,6 +1881,46 @@
|
||||
],
|
||||
"title": "ImportSessionResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CreateSourceResponse"
|
||||
}
|
||||
],
|
||||
"title": "CreateSourceResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ListSourcesResponse"
|
||||
}
|
||||
],
|
||||
"title": "ListSourcesResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/UpdateSourceResponse"
|
||||
}
|
||||
],
|
||||
"title": "UpdateSourceResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ExportSourceResponse"
|
||||
}
|
||||
],
|
||||
"title": "ExportSourceResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ImportSourcesResponse"
|
||||
}
|
||||
],
|
||||
"title": "ImportSourcesResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -3115,6 +3115,85 @@ impl GooseAcpAgent {
|
||||
Ok(EmptyResponse {})
|
||||
}
|
||||
|
||||
#[custom_method(CreateSourceRequest)]
|
||||
async fn on_create_source(
|
||||
&self,
|
||||
req: CreateSourceRequest,
|
||||
) -> Result<CreateSourceResponse, sacp::Error> {
|
||||
let source = goose::sources::create_source(
|
||||
req.source_type,
|
||||
&req.name,
|
||||
&req.description,
|
||||
&req.content,
|
||||
req.global,
|
||||
req.project_dir.as_deref(),
|
||||
)?;
|
||||
Ok(CreateSourceResponse { source })
|
||||
}
|
||||
|
||||
#[custom_method(ListSourcesRequest)]
|
||||
async fn on_list_sources(
|
||||
&self,
|
||||
req: ListSourcesRequest,
|
||||
) -> Result<ListSourcesResponse, sacp::Error> {
|
||||
let sources = goose::sources::list_sources(req.source_type, req.project_dir.as_deref())?;
|
||||
Ok(ListSourcesResponse { sources })
|
||||
}
|
||||
|
||||
#[custom_method(UpdateSourceRequest)]
|
||||
async fn on_update_source(
|
||||
&self,
|
||||
req: UpdateSourceRequest,
|
||||
) -> Result<UpdateSourceResponse, sacp::Error> {
|
||||
let source = goose::sources::update_source(
|
||||
req.source_type,
|
||||
&req.name,
|
||||
&req.description,
|
||||
&req.content,
|
||||
req.global,
|
||||
req.project_dir.as_deref(),
|
||||
)?;
|
||||
Ok(UpdateSourceResponse { source })
|
||||
}
|
||||
|
||||
#[custom_method(DeleteSourceRequest)]
|
||||
async fn on_delete_source(
|
||||
&self,
|
||||
req: DeleteSourceRequest,
|
||||
) -> Result<EmptyResponse, sacp::Error> {
|
||||
goose::sources::delete_source(
|
||||
req.source_type,
|
||||
&req.name,
|
||||
req.global,
|
||||
req.project_dir.as_deref(),
|
||||
)?;
|
||||
Ok(EmptyResponse {})
|
||||
}
|
||||
|
||||
#[custom_method(ExportSourceRequest)]
|
||||
async fn on_export_source(
|
||||
&self,
|
||||
req: ExportSourceRequest,
|
||||
) -> Result<ExportSourceResponse, sacp::Error> {
|
||||
let (json, filename) = goose::sources::export_source(
|
||||
req.source_type,
|
||||
&req.name,
|
||||
req.global,
|
||||
req.project_dir.as_deref(),
|
||||
)?;
|
||||
Ok(ExportSourceResponse { json, filename })
|
||||
}
|
||||
|
||||
#[custom_method(ImportSourcesRequest)]
|
||||
async fn on_import_sources(
|
||||
&self,
|
||||
req: ImportSourcesRequest,
|
||||
) -> Result<ImportSourcesResponse, sacp::Error> {
|
||||
let sources =
|
||||
goose::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?;
|
||||
Ok(ImportSourcesResponse { sources })
|
||||
}
|
||||
|
||||
#[custom_method(DictationTranscribeRequest)]
|
||||
async fn on_dictation_transcribe(
|
||||
&self,
|
||||
|
||||
@@ -296,6 +296,144 @@ pub struct ProviderConfigKey {
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
/// The type of source entity.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SourceType {
|
||||
#[default]
|
||||
Skill,
|
||||
}
|
||||
|
||||
/// A source — a user-editable entity backed by an on-disk directory. Sources
|
||||
/// may be either `global` (shared across all projects) or project-specific.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SourceEntry {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: SourceType,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
/// Absolute path to the source's directory on disk.
|
||||
pub directory: String,
|
||||
/// True when the source lives in the user's global sources directory; false
|
||||
/// when it lives inside a specific project.
|
||||
pub global: bool,
|
||||
}
|
||||
|
||||
/// Create a new source (global or project-scoped).
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/sources/create", response = CreateSourceResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSourceRequest {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: SourceType,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub global: bool,
|
||||
/// Absolute path to the project root. Required when `global` is false.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSourceResponse {
|
||||
pub source: SourceEntry,
|
||||
}
|
||||
|
||||
/// List sources. If `type` is omitted, sources of all known types are returned.
|
||||
/// Both global and project-scoped sources are included when `project_dir` is set.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/sources/list", response = ListSourcesResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListSourcesRequest {
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_type: Option<SourceType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListSourcesResponse {
|
||||
pub sources: Vec<SourceEntry>,
|
||||
}
|
||||
|
||||
/// Update an existing source's description and content.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/sources/update", response = UpdateSourceResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateSourceRequest {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: SourceType,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub global: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateSourceResponse {
|
||||
pub source: SourceEntry,
|
||||
}
|
||||
|
||||
/// Delete a source and its on-disk directory.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/sources/delete", response = EmptyResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteSourceRequest {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: SourceType,
|
||||
pub name: String,
|
||||
pub global: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// Export a source as a portable JSON payload.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/sources/export", response = ExportSourceResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportSourceRequest {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: SourceType,
|
||||
pub name: String,
|
||||
pub global: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportSourceResponse {
|
||||
pub json: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
/// Import a source from a JSON export payload produced by `_goose/sources/export`.
|
||||
/// The imported source is written under the given scope; on name collisions a
|
||||
/// `-imported` suffix is appended.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/sources/import", response = ImportSourcesResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportSourcesRequest {
|
||||
pub data: String,
|
||||
pub global: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportSourcesResponse {
|
||||
pub sources: Vec<SourceEntry>,
|
||||
}
|
||||
|
||||
/// Transcribe audio via a dictation provider.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/dictation/transcribe", response = DictationTranscribeResponse)]
|
||||
|
||||
@@ -113,6 +113,7 @@ strum = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
etcetera = { workspace = true }
|
||||
fs-err = "3"
|
||||
goose-sdk = { path = "../goose-sdk" }
|
||||
rand = { workspace = true }
|
||||
utoipa = { workspace = true, features = ["chrono"] }
|
||||
tokio-cron-scheduler = "0.14.0"
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod security;
|
||||
pub mod session;
|
||||
pub mod session_context;
|
||||
pub mod slash_commands;
|
||||
pub mod sources;
|
||||
pub mod subprocess;
|
||||
pub mod token_counter;
|
||||
pub mod tool_inspection;
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom
|
||||
//! methods. A source is a user-editable entity stored under a per-scope root
|
||||
//! directory — `~/.agents/skills` for global sources and `<project>/.goose/skills`
|
||||
//! for project-specific sources.
|
||||
|
||||
use crate::agents::platform_extensions::parse_frontmatter;
|
||||
use fs_err as fs;
|
||||
use goose_sdk::custom_requests::{SourceEntry, SourceType};
|
||||
use sacp::Error;
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SkillFront {
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
}
|
||||
|
||||
const GLOBAL_SKILLS_SUBPATH: &[&str] = &[".agents", "skills"];
|
||||
const PROJECT_SKILLS_SUBPATH: &[&str] = &[".goose", "skills"];
|
||||
|
||||
fn home_dir() -> Result<PathBuf, Error> {
|
||||
dirs::home_dir()
|
||||
.ok_or_else(|| Error::internal_error().data("Could not determine home directory"))
|
||||
}
|
||||
|
||||
fn skills_dir_global() -> Result<PathBuf, Error> {
|
||||
let mut dir = home_dir()?;
|
||||
for part in GLOBAL_SKILLS_SUBPATH {
|
||||
dir = dir.join(part);
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn skills_dir_project(project_dir: &str) -> Result<PathBuf, Error> {
|
||||
if project_dir.trim().is_empty() {
|
||||
return Err(
|
||||
Error::invalid_params().data("projectDir must not be empty when global is false")
|
||||
);
|
||||
}
|
||||
let mut dir = PathBuf::from(project_dir);
|
||||
for part in PROJECT_SKILLS_SUBPATH {
|
||||
dir = dir.join(part);
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn source_base_dir(
|
||||
source_type: SourceType,
|
||||
global: bool,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<PathBuf, Error> {
|
||||
match source_type {
|
||||
SourceType::Skill => {
|
||||
if global {
|
||||
skills_dir_global()
|
||||
} else {
|
||||
let pd = project_dir.ok_or_else(|| {
|
||||
Error::invalid_params().data("projectDir is required when global is false")
|
||||
})?;
|
||||
skills_dir_project(pd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kebab-case validation: `^[a-z0-9]+(-[a-z0-9]+)*$`. Prevents path traversal
|
||||
/// via names like `../../.ssh/authorized_keys`.
|
||||
fn validate_source_name(name: &str) -> Result<(), Error> {
|
||||
if name.is_empty() {
|
||||
return Err(Error::invalid_params().data("Source name must not be empty"));
|
||||
}
|
||||
let mut expect_alnum = true;
|
||||
for ch in name.chars() {
|
||||
if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
|
||||
expect_alnum = false;
|
||||
} else if ch == '-' && !expect_alnum {
|
||||
expect_alnum = true;
|
||||
} else {
|
||||
return Err(Error::invalid_params().data(format!(
|
||||
"Invalid source name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \
|
||||
must not start or end with a hyphen or contain consecutive hyphens).",
|
||||
name
|
||||
)));
|
||||
}
|
||||
}
|
||||
if expect_alnum {
|
||||
return Err(Error::invalid_params().data(format!(
|
||||
"Invalid source name \"{}\". Names must not end with a hyphen.",
|
||||
name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_skill_md(name: &str, description: &str, content: &str) -> String {
|
||||
// YAML single-quoted strings escape a literal single quote by doubling it.
|
||||
let safe_desc = description.replace('\'', "''");
|
||||
let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc);
|
||||
if !content.is_empty() {
|
||||
md.push('\n');
|
||||
md.push_str(content);
|
||||
md.push('\n');
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
fn parse_skill_frontmatter(raw: &str) -> (String, String) {
|
||||
if !raw.trim_start().starts_with("---") {
|
||||
return (String::new(), raw.to_string());
|
||||
}
|
||||
match parse_frontmatter::<SkillFront>(raw) {
|
||||
Ok(Some((meta, body))) => (meta.description, body),
|
||||
_ => (String::new(), raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_entry(
|
||||
source_type: SourceType,
|
||||
name: &str,
|
||||
description: &str,
|
||||
content: &str,
|
||||
dir: &Path,
|
||||
global: bool,
|
||||
) -> SourceEntry {
|
||||
SourceEntry {
|
||||
source_type,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
content: content.to_string(),
|
||||
directory: dir.to_string_lossy().to_string(),
|
||||
global,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_source(
|
||||
source_type: SourceType,
|
||||
name: &str,
|
||||
description: &str,
|
||||
content: &str,
|
||||
global: bool,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<SourceEntry, Error> {
|
||||
validate_source_name(name)?;
|
||||
let dir = source_base_dir(source_type, global, project_dir)?.join(name);
|
||||
|
||||
if dir.exists() {
|
||||
return Err(
|
||||
Error::invalid_params().data(format!("A source named \"{}\" already exists", name))
|
||||
);
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dir).map_err(|e| {
|
||||
Error::internal_error().data(format!("Failed to create source directory: {e}"))
|
||||
})?;
|
||||
let file_path = dir.join("SKILL.md");
|
||||
let md = build_skill_md(name, description, content);
|
||||
fs::write(&file_path, md)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?;
|
||||
|
||||
Ok(source_entry(
|
||||
source_type,
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
&dir,
|
||||
global,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn update_source(
|
||||
source_type: SourceType,
|
||||
name: &str,
|
||||
description: &str,
|
||||
content: &str,
|
||||
global: bool,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<SourceEntry, Error> {
|
||||
validate_source_name(name)?;
|
||||
let dir = source_base_dir(source_type, global, project_dir)?.join(name);
|
||||
|
||||
if !dir.exists() {
|
||||
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name)));
|
||||
}
|
||||
|
||||
let file_path = dir.join("SKILL.md");
|
||||
let md = build_skill_md(name, description, content);
|
||||
fs::write(&file_path, md)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?;
|
||||
|
||||
Ok(source_entry(
|
||||
source_type,
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
&dir,
|
||||
global,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn delete_source(
|
||||
source_type: SourceType,
|
||||
name: &str,
|
||||
global: bool,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
validate_source_name(name)?;
|
||||
let dir = source_base_dir(source_type, global, project_dir)?.join(name);
|
||||
|
||||
if !dir.exists() {
|
||||
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name)));
|
||||
}
|
||||
fs::remove_dir_all(&dir)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_sources(
|
||||
source_type: Option<SourceType>,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<Vec<SourceEntry>, Error> {
|
||||
let kinds: Vec<SourceType> = match source_type {
|
||||
Some(k) => vec![k],
|
||||
None => vec![SourceType::Skill],
|
||||
};
|
||||
|
||||
let mut sources = Vec::new();
|
||||
for kind in kinds {
|
||||
match kind {
|
||||
SourceType::Skill => {
|
||||
if let Some(pd) = project_dir {
|
||||
if !pd.trim().is_empty() {
|
||||
let dir = skills_dir_project(pd)?;
|
||||
sources.extend(read_skill_dir(&dir, false)?);
|
||||
}
|
||||
}
|
||||
let dir = skills_dir_global()?;
|
||||
sources.extend(read_skill_dir(&dir, true)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
sources.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
fn read_skill_dir(dir: &Path, global: bool) -> Result<Vec<SourceEntry>, Error> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let entries = fs::read_dir(dir)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to read skills dir: {e}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let raw = fs::read_to_string(&skill_md).unwrap_or_default();
|
||||
let (description, content) = parse_skill_frontmatter(&raw);
|
||||
out.push(source_entry(
|
||||
SourceType::Skill,
|
||||
&name,
|
||||
&description,
|
||||
&content,
|
||||
&path,
|
||||
global,
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn export_source(
|
||||
source_type: SourceType,
|
||||
name: &str,
|
||||
global: bool,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<(String, String), Error> {
|
||||
validate_source_name(name)?;
|
||||
let dir = source_base_dir(source_type, global, project_dir)?.join(name);
|
||||
|
||||
if !dir.exists() {
|
||||
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name)));
|
||||
}
|
||||
|
||||
let md = dir.join("SKILL.md");
|
||||
let raw = fs::read_to_string(&md)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?;
|
||||
let (description, content) = parse_skill_frontmatter(&raw);
|
||||
|
||||
let type_slug = match source_type {
|
||||
SourceType::Skill => "skill",
|
||||
};
|
||||
let export = serde_json::json!({
|
||||
"version": 1,
|
||||
"type": type_slug,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"content": content,
|
||||
});
|
||||
let json = serde_json::to_string_pretty(&export)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?;
|
||||
let filename = format!("{}.{}.json", name, type_slug);
|
||||
Ok((json, filename))
|
||||
}
|
||||
|
||||
pub fn import_sources(
|
||||
data: &str,
|
||||
global: bool,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<Vec<SourceEntry>, Error> {
|
||||
let value: serde_json::Value = serde_json::from_str(data)
|
||||
.map_err(|e| Error::invalid_params().data(format!("Invalid JSON: {e}")))?;
|
||||
|
||||
let version = value
|
||||
.get("version")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing or invalid \"version\" field"))?;
|
||||
if version != 1 {
|
||||
return Err(
|
||||
Error::invalid_params().data(format!("Unsupported source export version: {}", version))
|
||||
);
|
||||
}
|
||||
|
||||
// Default to `skill` to preserve compatibility with pre-sources skill exports.
|
||||
let source_type = match value
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("skill")
|
||||
{
|
||||
"skill" => SourceType::Skill,
|
||||
other => {
|
||||
return Err(Error::invalid_params().data(format!("Unsupported source type: {}", other)));
|
||||
}
|
||||
};
|
||||
|
||||
let name = value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))?
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
return Err(Error::invalid_params().data("Source name must not be empty"));
|
||||
}
|
||||
|
||||
let description = value
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))?
|
||||
.to_string();
|
||||
if description.is_empty() {
|
||||
return Err(Error::invalid_params().data("Source description must not be empty"));
|
||||
}
|
||||
|
||||
// Accept both the new `content` key and the legacy skills `instructions` key.
|
||||
let content = value
|
||||
.get("content")
|
||||
.or_else(|| value.get("instructions"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
validate_source_name(&name)?;
|
||||
|
||||
let base = source_base_dir(source_type, global, project_dir)?;
|
||||
let mut final_name = name.clone();
|
||||
if base.join(&final_name).exists() {
|
||||
final_name = format!("{}-imported", name);
|
||||
let mut counter = 2u32;
|
||||
while base.join(&final_name).exists() {
|
||||
final_name = format!("{}-imported-{}", name, counter);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let dir = base.join(&final_name);
|
||||
fs::create_dir_all(&dir).map_err(|e| {
|
||||
Error::internal_error().data(format!("Failed to create source directory: {e}"))
|
||||
})?;
|
||||
let file_path = dir.join("SKILL.md");
|
||||
let md = build_skill_md(&final_name, &description, &content);
|
||||
fs::write(&file_path, md)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?;
|
||||
|
||||
Ok(vec![source_entry(
|
||||
source_type,
|
||||
&final_name,
|
||||
&description,
|
||||
&content,
|
||||
&dir,
|
||||
global,
|
||||
)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn kebab_case_validation() {
|
||||
assert!(validate_source_name("my-skill").is_ok());
|
||||
assert!(validate_source_name("abc123").is_ok());
|
||||
assert!(validate_source_name("").is_err());
|
||||
assert!(validate_source_name("-leading").is_err());
|
||||
assert!(validate_source_name("trailing-").is_err());
|
||||
assert!(validate_source_name("double--hyphen").is_err());
|
||||
assert!(validate_source_name("CAPS").is_err());
|
||||
assert!(validate_source_name("../escape").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_list_update_delete_project_skill() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project = tmp.path().to_str().unwrap();
|
||||
|
||||
let created = create_source(
|
||||
SourceType::Skill,
|
||||
"my-skill",
|
||||
"does the thing",
|
||||
"step one\nstep two",
|
||||
false,
|
||||
Some(project),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(created.name, "my-skill");
|
||||
assert!(!created.global);
|
||||
assert!(PathBuf::from(&created.directory).join("SKILL.md").exists());
|
||||
|
||||
let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap();
|
||||
assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global));
|
||||
|
||||
let updated = update_source(
|
||||
SourceType::Skill,
|
||||
"my-skill",
|
||||
"now does a different thing",
|
||||
"step three",
|
||||
false,
|
||||
Some(project),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.description, "now does a different thing");
|
||||
|
||||
delete_source(SourceType::Skill, "my-skill", false, Some(project)).unwrap();
|
||||
assert!(!PathBuf::from(&created.directory).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_rejects_duplicate_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project = tmp.path().to_str().unwrap();
|
||||
|
||||
create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap();
|
||||
let err =
|
||||
create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap_err();
|
||||
assert!(format!("{:?}", err).contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scope_requires_project_dir() {
|
||||
let err = create_source(SourceType::Skill, "x", "d", "c", false, None).unwrap_err();
|
||||
assert!(format!("{:?}", err).contains("projectDir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_then_import_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_a = tmp.path().join("a");
|
||||
let project_b = tmp.path().join("b");
|
||||
std::fs::create_dir_all(&project_a).unwrap();
|
||||
std::fs::create_dir_all(&project_b).unwrap();
|
||||
|
||||
create_source(
|
||||
SourceType::Skill,
|
||||
"portable",
|
||||
"describes itself",
|
||||
"body goes here",
|
||||
false,
|
||||
Some(project_a.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (json, filename) = export_source(
|
||||
SourceType::Skill,
|
||||
"portable",
|
||||
false,
|
||||
Some(project_a.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(filename, "portable.skill.json");
|
||||
|
||||
let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap();
|
||||
assert_eq!(imported.len(), 1);
|
||||
assert_eq!(imported[0].name, "portable");
|
||||
assert_eq!(imported[0].description, "describes itself");
|
||||
assert_eq!(imported[0].content, "body goes here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_collision_appends_suffix() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project = tmp.path().to_str().unwrap();
|
||||
|
||||
create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"version": 1,
|
||||
"type": "skill",
|
||||
"name": "busy",
|
||||
"description": "d",
|
||||
"content": "c",
|
||||
})
|
||||
.to_string();
|
||||
let imported = import_sources(&payload, false, Some(project)).unwrap();
|
||||
assert_eq!(imported[0].name, "busy-imported");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
use std::env;
|
||||
|
||||
use crate::services::acp::GooseServeProcess;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_goose_serve_url(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
if let Ok(url) = env::var("GOOSE_SERVE_URL") {
|
||||
if !url.is_empty() {
|
||||
return Ok(url);
|
||||
}
|
||||
}
|
||||
let process = GooseServeProcess::get(app_handle).await?;
|
||||
Ok(process.ws_url())
|
||||
}
|
||||
|
||||
@@ -9,5 +9,4 @@ pub mod git_changes;
|
||||
pub mod model_setup;
|
||||
pub mod path_resolver;
|
||||
pub mod projects;
|
||||
pub mod skills;
|
||||
pub mod system;
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn skills_dir() -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
|
||||
Ok(home.join(".agents").join("skills"))
|
||||
}
|
||||
|
||||
/// Validates that a skill name is kebab-case only: `^[a-z0-9]+(-[a-z0-9]+)*$`.
|
||||
/// This prevents path traversal attacks (e.g. `../../.ssh/authorized_keys`).
|
||||
fn validate_skill_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("Skill name must not be empty".to_string());
|
||||
}
|
||||
let mut expect_alnum = true; // true = next char must be [a-z0-9], false = can also be '-'
|
||||
for ch in name.chars() {
|
||||
if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
|
||||
expect_alnum = false;
|
||||
} else if ch == '-' && !expect_alnum {
|
||||
expect_alnum = true; // char after '-' must be [a-z0-9]
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Invalid skill name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \
|
||||
must not start or end with a hyphen or contain consecutive hyphens).",
|
||||
name
|
||||
));
|
||||
}
|
||||
}
|
||||
if expect_alnum {
|
||||
// name ended with '-'
|
||||
return Err(format!(
|
||||
"Invalid skill name \"{}\". Names must not end with a hyphen.",
|
||||
name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_skill_md(name: &str, description: &str, instructions: &str) -> String {
|
||||
// Escape embedded single quotes by doubling them, then wrap in single quotes
|
||||
// to prevent YAML injection in the description field.
|
||||
let safe_desc = description.replace('\'', "''");
|
||||
let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc);
|
||||
if !instructions.is_empty() {
|
||||
md.push('\n');
|
||||
md.push_str(instructions);
|
||||
md.push('\n');
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_skill(name: String, description: String, instructions: String) -> Result<(), String> {
|
||||
validate_skill_name(&name)?;
|
||||
let dir = skills_dir()?.join(&name);
|
||||
|
||||
if dir.exists() {
|
||||
return Err(format!("A skill named \"{}\" already exists", name));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?;
|
||||
|
||||
let skill_path = dir.join("SKILL.md");
|
||||
let content = build_skill_md(&name, &description, &instructions);
|
||||
|
||||
fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
let dir = skills_dir()?;
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut skills = Vec::new();
|
||||
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read skills dir: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let raw = fs::read_to_string(&skill_md).unwrap_or_default();
|
||||
let (description, instructions) = parse_frontmatter(&raw);
|
||||
|
||||
skills.push(SkillInfo {
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
path: skill_md.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_skill(name: String) -> Result<(), String> {
|
||||
validate_skill_name(&name)?;
|
||||
let dir = skills_dir()?.join(&name);
|
||||
if !dir.exists() {
|
||||
return Err(format!("Skill \"{}\" not found", name));
|
||||
}
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete skill: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_frontmatter(raw: &str) -> (String, String) {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.starts_with("---") {
|
||||
return (String::new(), raw.to_string());
|
||||
}
|
||||
|
||||
if let Some(end) = trimmed[3..].find("\n---") {
|
||||
let front = &trimmed[3..3 + end].trim();
|
||||
let body = trimmed[3 + end + 4..].trim().to_string();
|
||||
|
||||
let mut description = String::new();
|
||||
for line in front.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("description:") {
|
||||
let val = rest.trim();
|
||||
// Strip surrounding quotes (single or double)
|
||||
let unquoted = val
|
||||
.trim_start_matches(['\'', '"'])
|
||||
.trim_end_matches(['\'', '"']);
|
||||
description = if val.starts_with('\'') {
|
||||
// Un-escape doubled single quotes
|
||||
unquoted.replace("''", "'")
|
||||
} else {
|
||||
// Legacy double-quote format
|
||||
unquoted.replace("\\\"", "\"")
|
||||
}
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
(description, body)
|
||||
} else {
|
||||
(String::new(), raw.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct SkillInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub instructions: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillExportV1 {
|
||||
version: u32,
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
instructions: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportSkillResult {
|
||||
json: String,
|
||||
filename: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_skill(
|
||||
name: String,
|
||||
description: String,
|
||||
instructions: String,
|
||||
) -> Result<SkillInfo, String> {
|
||||
validate_skill_name(&name)?;
|
||||
let dir = skills_dir()?.join(&name);
|
||||
|
||||
if !dir.exists() {
|
||||
return Err(format!("Skill \"{}\" not found", name));
|
||||
}
|
||||
|
||||
let skill_path = dir.join("SKILL.md");
|
||||
let content = build_skill_md(&name, &description, &instructions);
|
||||
|
||||
fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?;
|
||||
|
||||
Ok(SkillInfo {
|
||||
name: name.clone(),
|
||||
description,
|
||||
instructions,
|
||||
path: skill_path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_skill(name: String) -> Result<ExportSkillResult, String> {
|
||||
validate_skill_name(&name)?;
|
||||
let dir = skills_dir()?.join(&name);
|
||||
|
||||
if !dir.exists() {
|
||||
return Err(format!("Skill \"{}\" not found", name));
|
||||
}
|
||||
|
||||
let skill_md = dir.join("SKILL.md");
|
||||
let raw =
|
||||
fs::read_to_string(&skill_md).map_err(|e| format!("Failed to read SKILL.md: {}", e))?;
|
||||
let (description, instructions) = parse_frontmatter(&raw);
|
||||
|
||||
let export = SkillExportV1 {
|
||||
version: 1,
|
||||
name: name.clone(),
|
||||
description,
|
||||
instructions,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&export)
|
||||
.map_err(|e| format!("Failed to serialize skill: {}", e))?;
|
||||
|
||||
let filename = format!("{}.skill.json", name);
|
||||
|
||||
Ok(ExportSkillResult { json, filename })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_skills(file_bytes: Vec<u8>, file_name: String) -> Result<Vec<SkillInfo>, String> {
|
||||
// Validate file extension
|
||||
if !file_name.ends_with(".skill.json") && !file_name.ends_with(".json") {
|
||||
return Err("File must have a .skill.json or .json extension".to_string());
|
||||
}
|
||||
|
||||
// Parse bytes as UTF-8
|
||||
let text =
|
||||
String::from_utf8(file_bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))?;
|
||||
|
||||
// Parse as JSON
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
|
||||
// Validate version
|
||||
let version = value
|
||||
.get("version")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or("Missing or invalid \"version\" field")?;
|
||||
if version != 1 {
|
||||
return Err(format!("Unsupported skill export version: {}", version));
|
||||
}
|
||||
|
||||
// Extract fields
|
||||
let name = value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing or invalid \"name\" field")?
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Skill name must not be empty".to_string());
|
||||
}
|
||||
|
||||
let description = value
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing or invalid \"description\" field")?
|
||||
.to_string();
|
||||
if description.is_empty() {
|
||||
return Err("Skill description must not be empty".to_string());
|
||||
}
|
||||
|
||||
let instructions = value
|
||||
.get("instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Validate the name
|
||||
validate_skill_name(&name)?;
|
||||
|
||||
// Determine final name, avoiding collisions
|
||||
let base_dir = skills_dir()?;
|
||||
let mut final_name = name.clone();
|
||||
if base_dir.join(&final_name).exists() {
|
||||
final_name = format!("{}-imported", name);
|
||||
// If that also exists, append a number
|
||||
let mut counter = 2u32;
|
||||
while base_dir.join(&final_name).exists() {
|
||||
final_name = format!("{}-imported-{}", name, counter);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the skill on disk
|
||||
let dir = base_dir.join(&final_name);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?;
|
||||
|
||||
let skill_path = dir.join("SKILL.md");
|
||||
let content = build_skill_md(&final_name, &description, &instructions);
|
||||
fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?;
|
||||
|
||||
Ok(vec![SkillInfo {
|
||||
name: final_name,
|
||||
description,
|
||||
instructions,
|
||||
path: skill_path.to_string_lossy().to_string(),
|
||||
}])
|
||||
}
|
||||
@@ -44,12 +44,6 @@ pub fn run() {
|
||||
commands::agents::save_persona_avatar_bytes,
|
||||
commands::agents::get_avatars_dir,
|
||||
commands::acp::get_goose_serve_url,
|
||||
commands::skills::create_skill,
|
||||
commands::skills::list_skills,
|
||||
commands::skills::delete_skill,
|
||||
commands::skills::update_skill,
|
||||
commands::skills::export_skill,
|
||||
commands::skills::import_skills,
|
||||
commands::projects::list_projects,
|
||||
commands::projects::create_project,
|
||||
commands::projects::update_project,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
@@ -7,20 +7,54 @@ export interface SkillInfo {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Shape returned by _goose/sources/*. Narrowed to skill-type sources here.
|
||||
interface SourceEntry {
|
||||
type: "skill";
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
directory: string;
|
||||
global: boolean;
|
||||
}
|
||||
|
||||
function toSkillInfo(source: SourceEntry): SkillInfo {
|
||||
return {
|
||||
name: source.name,
|
||||
description: source.description,
|
||||
instructions: source.content,
|
||||
path: source.directory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSkill(
|
||||
name: string,
|
||||
description: string,
|
||||
instructions: string,
|
||||
): Promise<void> {
|
||||
return invoke("create_skill", { name, description, instructions });
|
||||
const client = await getClient();
|
||||
await client.extMethod("_goose/sources/create", {
|
||||
type: "skill",
|
||||
name,
|
||||
description,
|
||||
content: instructions,
|
||||
global: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSkills(): Promise<SkillInfo[]> {
|
||||
return invoke("list_skills");
|
||||
const client = await getClient();
|
||||
const raw = await client.extMethod("_goose/sources/list", { type: "skill" });
|
||||
const sources = (raw.sources ?? []) as SourceEntry[];
|
||||
return sources.map(toSkillInfo);
|
||||
}
|
||||
|
||||
export async function deleteSkill(name: string): Promise<void> {
|
||||
return invoke("delete_skill", { name });
|
||||
const client = await getClient();
|
||||
await client.extMethod("_goose/sources/delete", {
|
||||
type: "skill",
|
||||
name,
|
||||
global: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSkill(
|
||||
@@ -28,21 +62,42 @@ export async function updateSkill(
|
||||
description: string,
|
||||
instructions: string,
|
||||
): Promise<SkillInfo> {
|
||||
return invoke("update_skill", { name, description, instructions });
|
||||
const client = await getClient();
|
||||
const raw = await client.extMethod("_goose/sources/update", {
|
||||
type: "skill",
|
||||
name,
|
||||
description,
|
||||
content: instructions,
|
||||
global: true,
|
||||
});
|
||||
return toSkillInfo(raw.source as SourceEntry);
|
||||
}
|
||||
|
||||
export async function exportSkill(
|
||||
name: string,
|
||||
): Promise<{ json: string; filename: string }> {
|
||||
return invoke("export_skill", { name });
|
||||
const client = await getClient();
|
||||
const raw = await client.extMethod("_goose/sources/export", {
|
||||
type: "skill",
|
||||
name,
|
||||
global: true,
|
||||
});
|
||||
return { json: raw.json as string, filename: raw.filename as string };
|
||||
}
|
||||
|
||||
export async function importSkills(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
): Promise<SkillInfo[]> {
|
||||
return invoke("import_skills", {
|
||||
fileBytes: Array.from(fileBytes),
|
||||
fileName,
|
||||
if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) {
|
||||
throw new Error("File must have a .skill.json or .json extension");
|
||||
}
|
||||
const data = new TextDecoder().decode(new Uint8Array(fileBytes));
|
||||
const client = await getClient();
|
||||
const raw = await client.extMethod("_goose/sources/import", {
|
||||
data,
|
||||
global: true,
|
||||
});
|
||||
const sources = (raw.sources ?? []) as SourceEntry[];
|
||||
return sources.map(toSkillInfo);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
* Playwright custom fixture that injects a Tauri IPC mock into the page
|
||||
* before every navigation. This allows E2E tests to run against the frontend
|
||||
* without the real Tauri backend.
|
||||
*
|
||||
* Also installs a `window.WebSocket` stub for the ACP connection so features
|
||||
* like skills (which use `client.extMethod("_goose/sources/...")`) can run
|
||||
* without a live goose-acp server.
|
||||
*/
|
||||
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
@@ -11,7 +15,7 @@ import { MOCK_PERSONAS, MOCK_PROJECTS, MOCK_SKILLS } from "./mock-data";
|
||||
* Build the init script that will be injected into the page via
|
||||
* `page.addInitScript()`. The script sets up `window.__TAURI_INTERNALS__`
|
||||
* with an `invoke` handler that returns mock data for every Tauri command
|
||||
* the app is known to call.
|
||||
* the app is known to call, plus a WebSocket mock for ACP traffic.
|
||||
*
|
||||
* Callers can override the default personas and skills arrays to test
|
||||
* empty-state or custom scenarios.
|
||||
@@ -30,8 +34,18 @@ export function buildInitScript(options?: {
|
||||
const PERSONAS = ${personas};
|
||||
const SKILLS = ${skills};
|
||||
const PROJECTS = ${projects};
|
||||
const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp";
|
||||
const ACP_SESSIONS = [];
|
||||
|
||||
const skillToSourceEntry = (s) => ({
|
||||
type: "skill",
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
content: s.instructions ?? s.content ?? "",
|
||||
directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""),
|
||||
global: true,
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -120,6 +134,39 @@ export function buildInitScript(options?: {
|
||||
case "_goose/working_dir/update":
|
||||
case "goose/working_dir/update":
|
||||
return jsonRpcResult(message.id, {});
|
||||
case "_goose/sources/list":
|
||||
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
|
||||
case "_goose/sources/create":
|
||||
return jsonRpcResult(message.id, {
|
||||
source: {
|
||||
name: message.params?.name ?? "new-skill",
|
||||
type: "skill",
|
||||
description: message.params?.description ?? "",
|
||||
content: message.params?.content ?? "",
|
||||
directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
|
||||
global: message.params?.global ?? true,
|
||||
},
|
||||
});
|
||||
case "_goose/sources/update":
|
||||
return jsonRpcResult(message.id, {
|
||||
source: {
|
||||
name: message.params?.name ?? "updated-skill",
|
||||
type: "skill",
|
||||
description: message.params?.description ?? "",
|
||||
content: message.params?.content ?? "",
|
||||
directory: "/mock/.agents/skills/" + (message.params?.name ?? "updated-skill"),
|
||||
global: message.params?.global ?? true,
|
||||
},
|
||||
});
|
||||
case "_goose/sources/delete":
|
||||
return jsonRpcResult(message.id, {});
|
||||
case "_goose/sources/export":
|
||||
return jsonRpcResult(message.id, {
|
||||
json: "{}",
|
||||
filename: (message.params?.name ?? "skill") + ".skill.json",
|
||||
});
|
||||
case "_goose/sources/import":
|
||||
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
|
||||
default:
|
||||
return jsonRpcResult(message.id, {});
|
||||
}
|
||||
@@ -165,6 +212,10 @@ export function buildInitScript(options?: {
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke(cmd, args) {
|
||||
switch (cmd) {
|
||||
// ---- ACP transport ----
|
||||
case "get_goose_serve_url":
|
||||
return Promise.resolve(FAKE_ACP_URL);
|
||||
|
||||
// ---- Personas ----
|
||||
case "list_personas":
|
||||
return Promise.resolve(PERSONAS);
|
||||
@@ -202,28 +253,6 @@ export function buildInitScript(options?: {
|
||||
case "import_personas":
|
||||
return Promise.resolve(PERSONAS);
|
||||
|
||||
// ---- Skills ----
|
||||
case "list_skills":
|
||||
return Promise.resolve(SKILLS);
|
||||
case "create_skill":
|
||||
return Promise.resolve(null);
|
||||
case "update_skill":
|
||||
return Promise.resolve({
|
||||
name: args?.name ?? "updated-skill",
|
||||
description: args?.description ?? "",
|
||||
instructions: args?.instructions ?? "",
|
||||
path: "",
|
||||
});
|
||||
case "delete_skill":
|
||||
return Promise.resolve(null);
|
||||
case "export_skill":
|
||||
return Promise.resolve({
|
||||
json: "{}",
|
||||
filename: "skill.json",
|
||||
});
|
||||
case "import_skills":
|
||||
return Promise.resolve(SKILLS);
|
||||
|
||||
// ---- Sessions / Misc ----
|
||||
case "list_sessions":
|
||||
return Promise.resolve(
|
||||
@@ -234,8 +263,6 @@ export function buildInitScript(options?: {
|
||||
messageCount: session.messageCount,
|
||||
})),
|
||||
);
|
||||
case "get_goose_serve_url":
|
||||
return Promise.resolve("ws://mock-goose");
|
||||
case "create_session":
|
||||
return Promise.resolve({
|
||||
id: "session-" + Math.random().toString(36).slice(2, 10),
|
||||
|
||||
@@ -12,7 +12,10 @@ import type {
|
||||
ArchiveSessionRequest,
|
||||
CheckSecretRequest,
|
||||
CheckSecretResponse,
|
||||
CreateSourceRequest,
|
||||
CreateSourceResponse,
|
||||
DeleteSessionRequest,
|
||||
DeleteSourceRequest,
|
||||
DictationConfigRequest,
|
||||
DictationConfigResponse,
|
||||
DictationModelCancelRequest,
|
||||
@@ -27,6 +30,8 @@ import type {
|
||||
DictationTranscribeResponse,
|
||||
ExportSessionRequest,
|
||||
ExportSessionResponse,
|
||||
ExportSourceRequest,
|
||||
ExportSourceResponse,
|
||||
GetExtensionsRequest,
|
||||
GetExtensionsResponse,
|
||||
GetProviderDetailsRequest,
|
||||
@@ -39,8 +44,12 @@ import type {
|
||||
GetToolsResponse,
|
||||
ImportSessionRequest,
|
||||
ImportSessionResponse,
|
||||
ImportSourcesRequest,
|
||||
ImportSourcesResponse,
|
||||
ListProvidersRequest,
|
||||
ListProvidersResponse,
|
||||
ListSourcesRequest,
|
||||
ListSourcesResponse,
|
||||
ReadConfigRequest,
|
||||
ReadConfigResponse,
|
||||
ReadResourceRequest,
|
||||
@@ -51,27 +60,34 @@ import type {
|
||||
RemoveExtensionRequest,
|
||||
RemoveSecretRequest,
|
||||
UnarchiveSessionRequest,
|
||||
UpdateSourceRequest,
|
||||
UpdateSourceResponse,
|
||||
UpdateWorkingDirRequest,
|
||||
UpsertConfigRequest,
|
||||
UpsertSecretRequest,
|
||||
} from './types.gen.js';
|
||||
import {
|
||||
zCheckSecretResponse,
|
||||
zCreateSourceResponse,
|
||||
zDictationConfigResponse,
|
||||
zDictationModelDownloadProgressResponse,
|
||||
zDictationModelsListResponse,
|
||||
zDictationTranscribeResponse,
|
||||
zExportSessionResponse,
|
||||
zExportSourceResponse,
|
||||
zGetExtensionsResponse,
|
||||
zGetProviderDetailsResponse,
|
||||
zGetProviderInventoryResponse,
|
||||
zGetSessionExtensionsResponse,
|
||||
zGetToolsResponse,
|
||||
zImportSessionResponse,
|
||||
zImportSourcesResponse,
|
||||
zListProvidersResponse,
|
||||
zListSourcesResponse,
|
||||
zReadConfigResponse,
|
||||
zReadResourceResponse,
|
||||
zRefreshProviderInventoryResponse,
|
||||
zUpdateSourceResponse,
|
||||
} from './zod.gen.js';
|
||||
|
||||
export class GooseExtClient {
|
||||
@@ -208,6 +224,45 @@ export class GooseExtClient {
|
||||
await this.conn.extMethod("_goose/session/unarchive", params);
|
||||
}
|
||||
|
||||
async GooseSourcesCreate(
|
||||
params: CreateSourceRequest,
|
||||
): Promise<CreateSourceResponse> {
|
||||
const raw = await this.conn.extMethod("_goose/sources/create", params);
|
||||
return zCreateSourceResponse.parse(raw) as CreateSourceResponse;
|
||||
}
|
||||
|
||||
async GooseSourcesList(
|
||||
params: ListSourcesRequest,
|
||||
): Promise<ListSourcesResponse> {
|
||||
const raw = await this.conn.extMethod("_goose/sources/list", params);
|
||||
return zListSourcesResponse.parse(raw) as ListSourcesResponse;
|
||||
}
|
||||
|
||||
async GooseSourcesUpdate(
|
||||
params: UpdateSourceRequest,
|
||||
): Promise<UpdateSourceResponse> {
|
||||
const raw = await this.conn.extMethod("_goose/sources/update", params);
|
||||
return zUpdateSourceResponse.parse(raw) as UpdateSourceResponse;
|
||||
}
|
||||
|
||||
async GooseSourcesDelete(params: DeleteSourceRequest): Promise<void> {
|
||||
await this.conn.extMethod("_goose/sources/delete", params);
|
||||
}
|
||||
|
||||
async GooseSourcesExport(
|
||||
params: ExportSourceRequest,
|
||||
): Promise<ExportSourceResponse> {
|
||||
const raw = await this.conn.extMethod("_goose/sources/export", params);
|
||||
return zExportSourceResponse.parse(raw) as ExportSourceResponse;
|
||||
}
|
||||
|
||||
async GooseSourcesImport(
|
||||
params: ImportSourcesRequest,
|
||||
): Promise<ImportSourcesResponse> {
|
||||
const raw = await this.conn.extMethod("_goose/sources/import", params);
|
||||
return zImportSourcesResponse.parse(raw) as ImportSourcesResponse;
|
||||
}
|
||||
|
||||
async GooseDictationTranscribe(
|
||||
params: DictationTranscribeRequest,
|
||||
): Promise<DictationTranscribeResponse> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderInventoryRequest, GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
|
||||
export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderInventoryRequest, GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, UnarchiveSessionRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
|
||||
|
||||
export const GOOSE_EXT_METHODS = [
|
||||
{
|
||||
@@ -113,6 +113,36 @@ export const GOOSE_EXT_METHODS = [
|
||||
requestType: "UnarchiveSessionRequest",
|
||||
responseType: "EmptyResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/sources/create",
|
||||
requestType: "CreateSourceRequest",
|
||||
responseType: "CreateSourceResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/sources/list",
|
||||
requestType: "ListSourcesRequest",
|
||||
responseType: "ListSourcesResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/sources/update",
|
||||
requestType: "UpdateSourceRequest",
|
||||
responseType: "UpdateSourceResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/sources/delete",
|
||||
requestType: "DeleteSourceRequest",
|
||||
responseType: "EmptyResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/sources/export",
|
||||
requestType: "ExportSourceRequest",
|
||||
responseType: "ExportSourceResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/sources/import",
|
||||
requestType: "ImportSourcesRequest",
|
||||
responseType: "ImportSourcesResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/dictation/transcribe",
|
||||
requestType: "DictationTranscribeRequest",
|
||||
|
||||
@@ -395,6 +395,119 @@ export type UnarchiveSessionRequest = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new source (global or project-scoped).
|
||||
*/
|
||||
export type CreateSourceRequest = {
|
||||
type: SourceType;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
global: boolean;
|
||||
/**
|
||||
* Absolute path to the project root. Required when `global` is false.
|
||||
*/
|
||||
projectDir?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The type of source entity.
|
||||
*/
|
||||
export type SourceType = 'skill';
|
||||
|
||||
export type CreateSourceResponse = {
|
||||
source: SourceEntry;
|
||||
};
|
||||
|
||||
/**
|
||||
* A source — a user-editable entity backed by an on-disk directory. Sources
|
||||
* may be either `global` (shared across all projects) or project-specific.
|
||||
*/
|
||||
export type SourceEntry = {
|
||||
type: SourceType;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
/**
|
||||
* Absolute path to the source's directory on disk.
|
||||
*/
|
||||
directory: string;
|
||||
/**
|
||||
* True when the source lives in the user's global sources directory; false
|
||||
* when it lives inside a specific project.
|
||||
*/
|
||||
global: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* List sources. If `type` is omitted, sources of all known types are returned.
|
||||
* Both global and project-scoped sources are included when `project_dir` is set.
|
||||
*/
|
||||
export type ListSourcesRequest = {
|
||||
type?: SourceType | null;
|
||||
projectDir?: string | null;
|
||||
};
|
||||
|
||||
export type ListSourcesResponse = {
|
||||
sources: Array<SourceEntry>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing source's description and content.
|
||||
*/
|
||||
export type UpdateSourceRequest = {
|
||||
type: SourceType;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
global: boolean;
|
||||
projectDir?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateSourceResponse = {
|
||||
source: SourceEntry;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a source and its on-disk directory.
|
||||
*/
|
||||
export type DeleteSourceRequest = {
|
||||
type: SourceType;
|
||||
name: string;
|
||||
global: boolean;
|
||||
projectDir?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export a source as a portable JSON payload.
|
||||
*/
|
||||
export type ExportSourceRequest = {
|
||||
type: SourceType;
|
||||
name: string;
|
||||
global: boolean;
|
||||
projectDir?: string | null;
|
||||
};
|
||||
|
||||
export type ExportSourceResponse = {
|
||||
json: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Import a source from a JSON export payload produced by `_goose/sources/export`.
|
||||
* The imported source is written under the given scope; on name collisions a
|
||||
* `-imported` suffix is appended.
|
||||
*/
|
||||
export type ImportSourcesRequest = {
|
||||
data: string;
|
||||
global: boolean;
|
||||
projectDir?: string | null;
|
||||
};
|
||||
|
||||
export type ImportSourcesResponse = {
|
||||
sources: Array<SourceEntry>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transcribe audio via a dictation provider.
|
||||
*/
|
||||
@@ -535,14 +648,14 @@ export type DictationModelSelectRequest = {
|
||||
export type ExtRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderInventoryRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | {
|
||||
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderInventoryRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ExtResponse = {
|
||||
id: string;
|
||||
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderInventoryResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown;
|
||||
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderInventoryResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown;
|
||||
} | {
|
||||
error: {
|
||||
code: number;
|
||||
|
||||
@@ -348,6 +348,130 @@ export const zUnarchiveSessionRequest = z.object({
|
||||
sessionId: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* The type of source entity.
|
||||
*/
|
||||
export const zSourceType = z.enum(['skill']);
|
||||
|
||||
/**
|
||||
* Create a new source (global or project-scoped).
|
||||
*/
|
||||
export const zCreateSourceRequest = z.object({
|
||||
type: zSourceType,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
content: z.string(),
|
||||
global: z.boolean(),
|
||||
projectDir: z.union([
|
||||
z.string(),
|
||||
z.null()
|
||||
]).optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* A source — a user-editable entity backed by an on-disk directory. Sources
|
||||
* may be either `global` (shared across all projects) or project-specific.
|
||||
*/
|
||||
export const zSourceEntry = z.object({
|
||||
type: zSourceType,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
content: z.string(),
|
||||
directory: z.string(),
|
||||
global: z.boolean()
|
||||
});
|
||||
|
||||
export const zCreateSourceResponse = z.object({
|
||||
source: zSourceEntry
|
||||
});
|
||||
|
||||
/**
|
||||
* List sources. If `type` is omitted, sources of all known types are returned.
|
||||
* Both global and project-scoped sources are included when `project_dir` is set.
|
||||
*/
|
||||
export const zListSourcesRequest = z.object({
|
||||
type: z.union([
|
||||
zSourceType,
|
||||
z.null()
|
||||
]).optional(),
|
||||
projectDir: z.union([
|
||||
z.string(),
|
||||
z.null()
|
||||
]).optional()
|
||||
});
|
||||
|
||||
export const zListSourcesResponse = z.object({
|
||||
sources: z.array(zSourceEntry)
|
||||
});
|
||||
|
||||
/**
|
||||
* Update an existing source's description and content.
|
||||
*/
|
||||
export const zUpdateSourceRequest = z.object({
|
||||
type: zSourceType,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
content: z.string(),
|
||||
global: z.boolean(),
|
||||
projectDir: z.union([
|
||||
z.string(),
|
||||
z.null()
|
||||
]).optional()
|
||||
});
|
||||
|
||||
export const zUpdateSourceResponse = z.object({
|
||||
source: zSourceEntry
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a source and its on-disk directory.
|
||||
*/
|
||||
export const zDeleteSourceRequest = z.object({
|
||||
type: zSourceType,
|
||||
name: z.string(),
|
||||
global: z.boolean(),
|
||||
projectDir: z.union([
|
||||
z.string(),
|
||||
z.null()
|
||||
]).optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Export a source as a portable JSON payload.
|
||||
*/
|
||||
export const zExportSourceRequest = z.object({
|
||||
type: zSourceType,
|
||||
name: z.string(),
|
||||
global: z.boolean(),
|
||||
projectDir: z.union([
|
||||
z.string(),
|
||||
z.null()
|
||||
]).optional()
|
||||
});
|
||||
|
||||
export const zExportSourceResponse = z.object({
|
||||
json: z.string(),
|
||||
filename: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* Import a source from a JSON export payload produced by `_goose/sources/export`.
|
||||
* The imported source is written under the given scope; on name collisions a
|
||||
* `-imported` suffix is appended.
|
||||
*/
|
||||
export const zImportSourcesRequest = z.object({
|
||||
data: z.string(),
|
||||
global: z.boolean(),
|
||||
projectDir: z.union([
|
||||
z.string(),
|
||||
z.null()
|
||||
]).optional()
|
||||
});
|
||||
|
||||
export const zImportSourcesResponse = z.object({
|
||||
sources: z.array(zSourceEntry)
|
||||
});
|
||||
|
||||
/**
|
||||
* Transcribe audio via a dictation provider.
|
||||
*/
|
||||
@@ -515,6 +639,12 @@ export const zExtRequest = z.object({
|
||||
zImportSessionRequest,
|
||||
zArchiveSessionRequest,
|
||||
zUnarchiveSessionRequest,
|
||||
zCreateSourceRequest,
|
||||
zListSourcesRequest,
|
||||
zUpdateSourceRequest,
|
||||
zDeleteSourceRequest,
|
||||
zExportSourceRequest,
|
||||
zImportSourcesRequest,
|
||||
zDictationTranscribeRequest,
|
||||
zDictationConfigRequest,
|
||||
zDictationModelsListRequest,
|
||||
@@ -549,6 +679,11 @@ export const zExtResponse = z.union([
|
||||
zCheckSecretResponse,
|
||||
zExportSessionResponse,
|
||||
zImportSessionResponse,
|
||||
zCreateSourceResponse,
|
||||
zListSourcesResponse,
|
||||
zUpdateSourceResponse,
|
||||
zExportSourceResponse,
|
||||
zImportSourcesResponse,
|
||||
zDictationTranscribeResponse,
|
||||
zDictationConfigResponse,
|
||||
zDictationModelsListResponse,
|
||||
|
||||
Reference in New Issue
Block a user