diff --git a/Cargo.lock b/Cargo.lock index 55365d2f2f0b..f91d4e36d6fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4376,6 +4376,7 @@ dependencies = [ "libc", "llama-cpp-2", "lru", + "mime_guess", "minijinja", "mockall", "nanoid", diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index c8fc781ce78b..136827ea44e3 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -647,6 +647,144 @@ pub struct ProviderInventoryEntryDto { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct EmptyResponse {} +/// Resolve the current user's home directory. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/system/home_dir", response = GetHomeDirResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetHomeDirRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetHomeDirResponse { + /// Absolute path to the user's home directory. + pub path: String, +} + +/// Check whether a path exists on disk. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/system/path_exists", response = PathExistsResponse)] +#[serde(rename_all = "camelCase")] +pub struct PathExistsRequest { + pub path: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct PathExistsResponse { + pub exists: bool, +} + +/// A single filesystem entry surfaced to the desktop UI. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeEntryDto { + pub name: String, + pub path: String, + /// `"file"` or `"directory"`. + pub kind: String, +} + +/// List the immediate children of a directory. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/system/list_directory_entries", + response = ListDirectoryEntriesResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ListDirectoryEntriesRequest { + pub path: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListDirectoryEntriesResponse { + pub entries: Vec, +} + +/// Metadata describing a single attachment path on disk. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentPathInfoDto { + pub name: String, + pub path: String, + /// `"file"` or `"directory"`. + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, +} + +/// Inspect a batch of attachment paths. Missing entries are silently skipped. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/system/inspect_attachment_paths", + response = InspectAttachmentPathsResponse +)] +#[serde(rename_all = "camelCase")] +pub struct InspectAttachmentPathsRequest { + pub paths: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct InspectAttachmentPathsResponse { + pub attachments: Vec, +} + +/// Walk one or more roots and return a sorted list of file paths suitable for +/// `@`-mention pickers. Honours `.gitignore`, hidden files, and symlink escapes. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/system/list_files_for_mentions", + response = ListFilesForMentionsResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ListFilesForMentionsRequest { + pub roots: Vec, + /// Maximum number of results to return. Clamped to 1..=5000; defaults to 1500. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_results: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListFilesForMentionsResponse { + pub files: Vec, +} + +/// Read an image attachment from disk and return it as a base64 payload. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/system/read_image_attachment", + response = ReadImageAttachmentResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ReadImageAttachmentRequest { + pub path: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ReadImageAttachmentResponse { + /// Base64-encoded image bytes. + pub base64: String, + /// MIME type detected from the path's extension (always starts with `image/`). + pub mime_type: String, +} + +/// Write a UTF-8 string to a path on disk, creating any missing parents. +/// +/// The desktop shell uses this to persist content the user has chosen via a +/// native file dialog (e.g. exported sessions). Tauri-backed file dialogs are +/// still owned by the desktop shell; only the actual write is delegated to +/// `goose serve`. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/system/write_file", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct WriteFileRequest { + pub path: String, + pub contents: String, +} + /// List available local Whisper models with their download status. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request( diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 7776b6eb0808..7360c11ce1d8 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -193,6 +193,7 @@ sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8"], goose-acp-macros = { path = "../goose-acp-macros" } tower-http = { workspace = true, features = ["cors"] } http-body-util = "0.1.3" +mime_guess = "2" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 252639c00a3b..312e7370012c 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -194,6 +194,41 @@ "method": "_goose/dictation/model/select", "requestType": "DictationModelSelectRequest", "responseType": "EmptyResponse" + }, + { + "method": "_goose/system/home_dir", + "requestType": "GetHomeDirRequest", + "responseType": "GetHomeDirResponse" + }, + { + "method": "_goose/system/path_exists", + "requestType": "PathExistsRequest", + "responseType": "PathExistsResponse" + }, + { + "method": "_goose/system/list_directory_entries", + "requestType": "ListDirectoryEntriesRequest", + "responseType": "ListDirectoryEntriesResponse" + }, + { + "method": "_goose/system/inspect_attachment_paths", + "requestType": "InspectAttachmentPathsRequest", + "responseType": "InspectAttachmentPathsResponse" + }, + { + "method": "_goose/system/list_files_for_mentions", + "requestType": "ListFilesForMentionsRequest", + "responseType": "ListFilesForMentionsResponse" + }, + { + "method": "_goose/system/read_image_attachment", + "requestType": "ReadImageAttachmentRequest", + "responseType": "ReadImageAttachmentResponse" + }, + { + "method": "_goose/system/write_file", + "requestType": "WriteFileRequest", + "responseType": "EmptyResponse" } ] } diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 1931938eabe2..33b781dcb250 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -1390,6 +1390,257 @@ "x-side": "agent", "x-method": "_goose/dictation/model/select" }, + "GetHomeDirRequest": { + "type": "object", + "description": "Resolve the current user's home directory.", + "x-side": "agent", + "x-method": "_goose/system/home_dir" + }, + "GetHomeDirResponse": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path to the user's home directory." + } + }, + "required": [ + "path" + ], + "x-side": "agent", + "x-method": "_goose/system/home_dir" + }, + "PathExistsRequest": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "description": "Check whether a path exists on disk.", + "x-side": "agent", + "x-method": "_goose/system/path_exists" + }, + "PathExistsResponse": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + } + }, + "required": [ + "exists" + ], + "x-side": "agent", + "x-method": "_goose/system/path_exists" + }, + "ListDirectoryEntriesRequest": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "description": "List the immediate children of a directory.", + "x-side": "agent", + "x-method": "_goose/system/list_directory_entries" + }, + "ListDirectoryEntriesResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/FileTreeEntryDto" + } + } + }, + "required": [ + "entries" + ], + "x-side": "agent", + "x-method": "_goose/system/list_directory_entries" + }, + "FileTreeEntryDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "`\"file\"` or `\"directory\"`." + } + }, + "required": [ + "name", + "path", + "kind" + ], + "description": "A single filesystem entry surfaced to the desktop UI." + }, + "InspectAttachmentPathsRequest": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "paths" + ], + "description": "Inspect a batch of attachment paths. Missing entries are silently skipped.", + "x-side": "agent", + "x-method": "_goose/system/inspect_attachment_paths" + }, + "InspectAttachmentPathsResponse": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/AttachmentPathInfoDto" + } + } + }, + "required": [ + "attachments" + ], + "x-side": "agent", + "x-method": "_goose/system/inspect_attachment_paths" + }, + "AttachmentPathInfoDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "`\"file\"` or `\"directory\"`." + }, + "mimeType": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "path", + "kind" + ], + "description": "Metadata describing a single attachment path on disk." + }, + "ListFilesForMentionsRequest": { + "type": "object", + "properties": { + "roots": { + "type": "array", + "items": { + "type": "string" + } + }, + "maxResults": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0, + "description": "Maximum number of results to return. Clamped to 1..=5000; defaults to 1500." + } + }, + "required": [ + "roots" + ], + "description": "Walk one or more roots and return a sorted list of file paths suitable for\n`@`-mention pickers. Honours `.gitignore`, hidden files, and symlink escapes.", + "x-side": "agent", + "x-method": "_goose/system/list_files_for_mentions" + }, + "ListFilesForMentionsResponse": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "files" + ], + "x-side": "agent", + "x-method": "_goose/system/list_files_for_mentions" + }, + "ReadImageAttachmentRequest": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "description": "Read an image attachment from disk and return it as a base64 payload.", + "x-side": "agent", + "x-method": "_goose/system/read_image_attachment" + }, + "ReadImageAttachmentResponse": { + "type": "object", + "properties": { + "base64": { + "type": "string", + "description": "Base64-encoded image bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type detected from the path's extension (always starts with `image/`)." + } + }, + "required": [ + "base64", + "mimeType" + ], + "x-side": "agent", + "x-method": "_goose/system/read_image_attachment" + }, + "WriteFileRequest": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "contents": { + "type": "string" + } + }, + "required": [ + "path", + "contents" + ], + "description": "Write a UTF-8 string to a path on disk, creating any missing parents.\n\nThe desktop shell uses this to persist content the user has chosen via a\nnative file dialog (e.g. exported sessions). Tauri-backed file dialogs are\nstill owned by the desktop shell; only the actual write is delegated to\n`goose serve`.", + "x-side": "agent", + "x-method": "_goose/system/write_file" + }, "ExtRequest": { "properties": { "id": { @@ -1752,6 +2003,69 @@ ], "description": "Params for _goose/dictation/model/select", "title": "DictationModelSelectRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetHomeDirRequest" + } + ], + "description": "Params for _goose/system/home_dir", + "title": "GetHomeDirRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PathExistsRequest" + } + ], + "description": "Params for _goose/system/path_exists", + "title": "PathExistsRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListDirectoryEntriesRequest" + } + ], + "description": "Params for _goose/system/list_directory_entries", + "title": "ListDirectoryEntriesRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/InspectAttachmentPathsRequest" + } + ], + "description": "Params for _goose/system/inspect_attachment_paths", + "title": "InspectAttachmentPathsRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListFilesForMentionsRequest" + } + ], + "description": "Params for _goose/system/list_files_for_mentions", + "title": "ListFilesForMentionsRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadImageAttachmentRequest" + } + ], + "description": "Params for _goose/system/read_image_attachment", + "title": "ReadImageAttachmentRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/WriteFileRequest" + } + ], + "description": "Params for _goose/system/write_file", + "title": "WriteFileRequest" } ] }, @@ -1942,6 +2256,54 @@ } ], "title": "DictationModelDownloadProgressResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetHomeDirResponse" + } + ], + "title": "GetHomeDirResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/PathExistsResponse" + } + ], + "title": "PathExistsResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListDirectoryEntriesResponse" + } + ], + "title": "ListDirectoryEntriesResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/InspectAttachmentPathsResponse" + } + ], + "title": "InspectAttachmentPathsResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListFilesForMentionsResponse" + } + ], + "title": "ListFilesForMentionsResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadImageAttachmentResponse" + } + ], + "title": "ReadImageAttachmentResponse" } ] }, diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index a7c873a1fe6f..f7dbe499c5d6 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3563,6 +3563,89 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } + + #[custom_method(GetHomeDirRequest)] + async fn on_get_home_dir(&self) -> Result { + let path = crate::system_ops::get_home_dir().invalid_params_err()?; + Ok(GetHomeDirResponse { path }) + } + + #[custom_method(PathExistsRequest)] + async fn on_path_exists( + &self, + req: PathExistsRequest, + ) -> Result { + Ok(PathExistsResponse { + exists: crate::system_ops::path_exists(&req.path), + }) + } + + #[custom_method(ListDirectoryEntriesRequest)] + async fn on_list_directory_entries( + &self, + req: ListDirectoryEntriesRequest, + ) -> Result { + let entries = crate::system_ops::list_directory_entries(&req.path).invalid_params_err()?; + Ok(ListDirectoryEntriesResponse { + entries: entries + .into_iter() + .map(|e| FileTreeEntryDto { + name: e.name, + path: e.path, + kind: e.kind, + }) + .collect(), + }) + } + + #[custom_method(InspectAttachmentPathsRequest)] + async fn on_inspect_attachment_paths( + &self, + req: InspectAttachmentPathsRequest, + ) -> Result { + let attachments = crate::system_ops::inspect_attachment_paths(req.paths) + .into_iter() + .map(|a| AttachmentPathInfoDto { + name: a.name, + path: a.path, + kind: a.kind, + mime_type: a.mime_type, + }) + .collect(); + Ok(InspectAttachmentPathsResponse { attachments }) + } + + #[custom_method(ListFilesForMentionsRequest)] + async fn on_list_files_for_mentions( + &self, + req: ListFilesForMentionsRequest, + ) -> Result { + let max_results = req.max_results.map(|n| n as usize); + let files = tokio::task::spawn_blocking(move || { + crate::system_ops::list_files_for_mentions(req.roots, max_results) + }) + .await + .internal_err()?; + Ok(ListFilesForMentionsResponse { files }) + } + + #[custom_method(ReadImageAttachmentRequest)] + async fn on_read_image_attachment( + &self, + req: ReadImageAttachmentRequest, + ) -> Result { + let payload = crate::system_ops::read_image_attachment(&req.path).invalid_params_err()?; + Ok(ReadImageAttachmentResponse { + base64: payload.base64, + mime_type: payload.mime_type, + }) + } + + #[custom_method(WriteFileRequest)] + async fn on_write_file(&self, req: WriteFileRequest) -> Result { + crate::system_ops::write_file(&req.path, &req.contents).internal_err()?; + Ok(EmptyResponse {}) + } } fn dictation_model_config_key(provider: DictationProvider) -> Option { diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index ab64d11ca496..ec7af5494718 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -42,6 +42,7 @@ pub mod skills; pub mod slash_commands; pub mod sources; pub mod subprocess; +pub mod system_ops; pub mod token_counter; pub mod tool_inspection; pub mod tool_monitor; diff --git a/ui/goose2/src-tauri/src/commands/system.rs b/crates/goose/src/system_ops.rs similarity index 80% rename from ui/goose2/src-tauri/src/commands/system.rs rename to crates/goose/src/system_ops.rs index 6dc45adcb0ea..38209287eea3 100644 --- a/ui/goose2/src-tauri/src/commands/system.rs +++ b/crates/goose/src/system_ops.rs @@ -1,18 +1,26 @@ -use base64::Engine; -use serde::Serialize; -use tauri::Window; -use tauri_plugin_dialog::DialogExt; +//! Filesystem and attachment helpers used by the ACP `_goose/system/*` methods. +//! +//! These helpers were originally implemented as Tauri commands in +//! `ui/goose2/src-tauri/src/commands/system.rs`. They were migrated here as +//! part of [#8692](https://github.com/aaif-goose/goose/issues/8692) so the +//! `goose serve` process owns the filesystem logic and the desktop shell can +//! stay a thin layer. use std::collections::HashSet; -use std::fs; use std::path::{Path, PathBuf}; +use base64::Engine; +use fs_err as fs; +use serde::{Deserialize, Serialize}; + const DEFAULT_FILE_MENTION_LIMIT: usize = 1500; const MAX_FILE_MENTION_LIMIT: usize = 5000; const MAX_SCAN_DEPTH: usize = 8; const MAX_IMAGE_ATTACHMENT_BYTES: u64 = 20 * 1024 * 1024; -#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +/// A single filesystem entry (file or directory) returned by +/// [`list_directory_entries`]. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct FileTreeEntry { pub name: String, @@ -20,7 +28,9 @@ pub struct FileTreeEntry { pub kind: String, } -#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +/// Metadata for a single attachment path, returned by +/// [`inspect_attachment_paths`]. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AttachmentPathInfo { pub name: String, @@ -30,58 +40,42 @@ pub struct AttachmentPathInfo { pub mime_type: Option, } -#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +/// Decoded image attachment payload. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ImageAttachmentPayload { pub base64: String, pub mime_type: String, } -#[tauri::command] +/// Resolve the current user's home directory. pub fn get_home_dir() -> Result { let home_dir = dirs::home_dir().ok_or("Could not determine home directory")?; Ok(home_dir.to_string_lossy().into_owned()) } -#[tauri::command] -pub async fn save_exported_session_file( - window: Window, - default_filename: String, - contents: String, -) -> Result, String> { - let desktop = - dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Desktop")); - - let mut dialog = window - .dialog() - .file() - .set_title("Export Session") - .set_file_name(default_filename) - .set_directory(desktop) - .add_filter("JSON", &["json"]); - - #[cfg(desktop)] - { - dialog = dialog.set_parent(&window); - } - - let Some(path) = dialog.blocking_save_file() else { - return Ok(None); - }; - - let path = path - .into_path() - .map_err(|_| "Selected save path is not available".to_string())?; - std::fs::write(&path, contents) - .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))?; +/// Returns true if `path` exists on disk. +pub fn path_exists(path: &str) -> bool { + Path::new(path).exists() +} - Ok(Some(path.to_string_lossy().into_owned())) +/// Write `contents` to `path`, creating any missing parent directories. +pub fn write_file(path: &str, contents: &str) -> Result<(), String> { + let path = Path::new(path); + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create '{}': {}", parent.display(), e))?; + } + } + fs::write(path, contents) + .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e)) } -#[tauri::command] -#[allow(dead_code)] -pub fn path_exists(path: String) -> bool { - std::path::Path::new(&path).exists() +/// List the immediate children of `path`, sorted directories-first then by +/// case-insensitive name. Skips `.git/` to match the desktop shell behaviour. +pub fn list_directory_entries(path: &str) -> Result, String> { + read_directory_entries(Path::new(path)) } fn read_directory_entries(path: &Path) -> Result, String> { @@ -100,9 +94,7 @@ fn read_directory_entries(path: &Path) -> Result, String> { .map_err(|error| format!("Failed to read directory '{}': {}", path.display(), error))?; for entry in reader { - let Ok(entry) = entry else { - continue; - }; + let Ok(entry) = entry else { continue }; let name = entry.file_name().to_string_lossy().into_owned(); if name == ".git" { continue; @@ -110,7 +102,6 @@ fn read_directory_entries(path: &Path) -> Result, String> { let Some(file_tree_entry) = build_file_tree_entry(entry.path(), name) else { continue; }; - entries.push(file_tree_entry); } @@ -141,11 +132,6 @@ fn build_file_tree_entry(path: PathBuf, name: String) -> Option { }) } -#[tauri::command] -pub fn list_directory_entries(path: String) -> Result, String> { - read_directory_entries(Path::new(&path)) -} - fn inspect_attachment_path(path: &Path) -> Result { if !path.exists() { return Err(format!( @@ -215,8 +201,10 @@ fn normalize_attachment_paths(paths: Vec) -> Vec { normalized } -#[tauri::command] -pub fn inspect_attachment_paths(paths: Vec) -> Result, String> { +/// Look up metadata for each path in `paths`. Missing entries are silently +/// skipped so callers can pass in a heterogeneous batch (e.g. drag-and-drop +/// payloads that include now-deleted paths). +pub fn inspect_attachment_paths(paths: Vec) -> Vec { let mut attachments = Vec::new(); for path in normalize_attachment_paths(paths) { @@ -225,12 +213,12 @@ pub fn inspect_attachment_paths(paths: Vec) -> Result Result { - let attachment = inspect_attachment_path(Path::new(&path))?; +/// Read an image file from disk and return it as a base64-encoded payload. +pub fn read_image_attachment(path: &str) -> Result { + let attachment = inspect_attachment_path(Path::new(path))?; let mime_type = attachment .mime_type .ok_or_else(|| format!("Unable to determine image type for '{}'", attachment.path))?; @@ -275,7 +263,9 @@ fn normalize_roots(roots: Vec) -> Vec { normalized } -fn scan_files_for_mentions(roots: Vec, max_results: Option) -> Vec { +/// Walk `roots` and return up to `max_results` file paths, respecting hidden / +/// gitignore rules so we don't surface things like `node_modules` or `.git`. +pub fn list_files_for_mentions(roots: Vec, max_results: Option) -> Vec { let roots = normalize_roots(roots); if roots.is_empty() { return Vec::new(); @@ -291,13 +281,13 @@ fn scan_files_for_mentions(roots: Vec, max_results: Option) -> Ve } builder .max_depth(Some(MAX_SCAN_DEPTH)) - .follow_links(false) // don't traverse symlinks - .hidden(true) // skip hidden files/dirs - .git_ignore(true) // respect .gitignore - .git_global(true) // respect global gitignore - .git_exclude(true); // respect .git/info/exclude + .follow_links(false) + .hidden(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true); - // Canonicalize roots so we can reject paths that escape via symlink targets + // Canonicalize roots so we can reject paths that escape via symlink targets. let canonical_roots: Vec = roots .iter() .filter_map(|root| root.canonicalize().ok()) @@ -316,7 +306,6 @@ fn scan_files_for_mentions(roots: Vec, max_results: Option) -> Ve if !ft.is_file() { continue; } - // Reject any path that resolved outside the project roots let canonical = match entry.path().canonicalize() { Ok(c) => c, Err(_) => continue, @@ -338,28 +327,13 @@ fn scan_files_for_mentions(roots: Vec, max_results: Option) -> Ve files } -#[tauri::command] -pub async fn list_files_for_mentions( - roots: Vec, - max_results: Option, -) -> Result, String> { - tokio::task::spawn_blocking(move || scan_files_for_mentions(roots, max_results)) - .await - .map_err(|error| format!("Failed to scan files for mentions: {}", error)) -} - #[cfg(test)] mod tests { - use super::{ - build_file_tree_entry, inspect_attachment_path, inspect_attachment_paths, - normalize_attachment_paths, normalize_roots, read_directory_entries, read_image_attachment, - scan_files_for_mentions, MAX_IMAGE_ATTACHMENT_BYTES, - }; + use super::*; use base64::Engine; use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; use std::process::Command; use tempfile::tempdir; @@ -387,7 +361,7 @@ mod tests { fs::write(ignored.join("index.js"), "module.exports = {}").expect("ignored file"); fs::write(root.join(".gitignore"), "node_modules/\n").expect(".gitignore"); - let files = scan_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50)); + let files = list_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50)); let joined = files.join("\n"); assert!(joined.contains("main.ts"), "should include source files"); @@ -405,7 +379,7 @@ mod tests { fs::write(root.join("visible.ts"), "").expect("visible file"); fs::write(root.join(".hidden"), "").expect("hidden file"); - let files = scan_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50)); + let files = list_files_for_mentions(vec![root.to_string_lossy().to_string()], Some(50)); let joined = files.join("\n"); assert!(joined.contains("visible.ts")); @@ -431,37 +405,37 @@ mod tests { assert_eq!( entries, vec![ - super::FileTreeEntry { + FileTreeEntry { name: ".github".into(), path: root.join(".github").to_string_lossy().into_owned(), kind: "directory".into(), }, - super::FileTreeEntry { + FileTreeEntry { name: "node_modules".into(), path: root.join("node_modules").to_string_lossy().into_owned(), kind: "directory".into(), }, - super::FileTreeEntry { + FileTreeEntry { name: "src".into(), path: root.join("src").to_string_lossy().into_owned(), kind: "directory".into(), }, - super::FileTreeEntry { + FileTreeEntry { name: ".env".into(), path: root.join(".env").to_string_lossy().into_owned(), kind: "file".into(), }, - super::FileTreeEntry { + FileTreeEntry { name: ".gitignore".into(), path: root.join(".gitignore").to_string_lossy().into_owned(), kind: "file".into(), }, - super::FileTreeEntry { + FileTreeEntry { name: "alpha.ts".into(), path: root.join("alpha.ts").to_string_lossy().into_owned(), kind: "file".into(), }, - super::FileTreeEntry { + FileTreeEntry { name: "README.md".into(), path: root.join("README.md").to_string_lossy().into_owned(), kind: "file".into(), @@ -542,7 +516,7 @@ mod tests { fs::write(&image, png_bytes).expect("png file"); - let payload = read_image_attachment(image.to_string_lossy().into_owned()).expect("payload"); + let payload = read_image_attachment(&image.to_string_lossy()).expect("payload"); assert_eq!(payload.mime_type, "image/png"); assert!(!payload.base64.is_empty()); @@ -579,8 +553,7 @@ mod tests { let attachments = inspect_attachment_paths(vec![ valid.to_string_lossy().into_owned(), missing.to_string_lossy().into_owned(), - ]) - .expect("attachments"); + ]); assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].name, "report.txt"); @@ -618,9 +591,31 @@ mod tests { ) .expect("oversized image file"); - let error = - read_image_attachment(image.to_string_lossy().into_owned()).expect_err("size limit"); + let error = read_image_attachment(&image.to_string_lossy()).expect_err("size limit"); assert!(error.contains("exceeds the 20 MB limit")); } + + #[test] + fn write_file_creates_missing_parent_directories() { + let dir = tempdir().expect("tempdir"); + let nested = dir.path().join("a/b/c/out.json"); + + write_file(&nested.to_string_lossy(), "{\"ok\":true}").expect("write"); + + let read_back = std::fs::read_to_string(&nested).expect("read back"); + assert_eq!(read_back, "{\"ok\":true}"); + } + + #[test] + fn path_exists_reflects_disk_state() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("hello.txt"); + std::fs::write(&file, "hi").expect("file"); + + assert!(path_exists(&file.to_string_lossy())); + assert!(!path_exists( + &dir.path().join("missing.txt").to_string_lossy() + )); + } } diff --git a/ui/goose2/src-tauri/Cargo.lock b/ui/goose2/src-tauri/Cargo.lock index 6fe4f563645b..12c97a98a486 100644 --- a/ui/goose2/src-tauri/Cargo.lock +++ b/ui/goose2/src-tauri/Cargo.lock @@ -397,16 +397,6 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -729,25 +719,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1638,19 +1609,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - [[package]] name = "gobject-sys" version = "0.18.0" @@ -1666,15 +1624,12 @@ dependencies = [ name = "goose2" version = "0.1.0" dependencies = [ - "base64 0.22.1", "chrono", "dirs", "doctor", "etcetera", - "ignore", "keyring", "log", - "mime_guess", "serde", "serde_json", "serde_yaml", @@ -2077,22 +2032,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -2490,16 +2429,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -5259,12 +5188,6 @@ dependencies = [ "unic-common", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/ui/goose2/src-tauri/Cargo.toml b/ui/goose2/src-tauri/Cargo.toml index 12376ef6d0c6..a1919f06fffb 100644 --- a/ui/goose2/src-tauri/Cargo.toml +++ b/ui/goose2/src-tauri/Cargo.toml @@ -25,7 +25,6 @@ tauri-plugin-window-state = "2" tauri-plugin-log = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -dirs = "6.0.0" log = "0.4.29" tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "1", features = ["v4", "serde"] } @@ -33,10 +32,8 @@ chrono = { version = "0.4", features = ["serde"] } serde_yaml = "0.9" etcetera = "0.8" doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" } -ignore = "0.4.25" -base64 = "0.22" -mime_guess = "2" tauri-plugin-shell = "2" +dirs = "6.0.0" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 51e933f64256..11f70df52009 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -8,4 +8,3 @@ pub mod git_changes; pub mod model_setup; pub mod path_resolver; pub mod projects; -pub mod system; diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index e82d11fa0d97..fc22ff2b5486 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -76,13 +76,6 @@ pub fn run() { commands::agent_setup::install_agent, commands::agent_setup::authenticate_agent, commands::path_resolver::resolve_path, - commands::system::get_home_dir, - commands::system::save_exported_session_file, - commands::system::path_exists, - commands::system::list_directory_entries, - commands::system::inspect_attachment_paths, - commands::system::list_files_for_mentions, - commands::system::read_image_attachment, ]) .setup(|_app| Ok(())) .build(tauri::generate_context!()) diff --git a/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx index 6b001b48816d..574a66674b33 100644 --- a/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx +++ b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx @@ -19,7 +19,7 @@ import { acpExportSession, acpImportSession, } from "@/shared/api/acp"; -import { saveExportedSessionFile } from "@/shared/api/system"; +import { writeFile } from "@/shared/api/system"; import { defaultExportFilename, downloadJson } from "../lib/exportSession"; import { useSessionSearch } from "../hooks/useSessionSearch"; @@ -114,10 +114,16 @@ export function SessionHistoryView({ const filename = defaultExportFilename(session?.title ?? "session"); if (window.__TAURI_INTERNALS__) { - const savedPath = await saveExportedSessionFile(filename, json); + const { save } = await import("@tauri-apps/plugin-dialog"); + const savedPath = await save({ + title: "Export Session", + defaultPath: filename, + filters: [{ name: "JSON", extensions: ["json"] }], + }); if (!savedPath) { return; } + await writeFile(savedPath, json); toast.success(`Exported session to ${filename}`); return; } diff --git a/ui/goose2/src/shared/api/system.ts b/ui/goose2/src/shared/api/system.ts index 9ae1e668905f..09bff36a0989 100644 --- a/ui/goose2/src/shared/api/system.ts +++ b/ui/goose2/src/shared/api/system.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { getClient } from "@/shared/api/acpConnection"; export interface FileTreeEntry { name: string; @@ -19,41 +19,70 @@ export interface ImageAttachmentPayload { } export async function getHomeDir(): Promise { - return invoke("get_home_dir"); -} - -export async function saveExportedSessionFile( - defaultFilename: string, - contents: string, -): Promise { - return invoke("save_exported_session_file", { defaultFilename, contents }); + const client = await getClient(); + const response = await client.goose.GooseSystemHomeDir({}); + return response.path; } export async function pathExists(path: string): Promise { - return invoke("path_exists", { path }); -} - -export async function listFilesForMentions( - roots: string[], - maxResults = 1500, -): Promise { - return invoke("list_files_for_mentions", { roots, maxResults }); + const client = await getClient(); + const response = await client.goose.GooseSystemPathExists({ path }); + return response.exists; } export async function listDirectoryEntries( path: string, ): Promise { - return invoke("list_directory_entries", { path }); + const client = await getClient(); + const response = await client.goose.GooseSystemListDirectoryEntries({ path }); + return response.entries.map((entry) => ({ + name: entry.name, + path: entry.path, + kind: entry.kind === "directory" ? "directory" : "file", + })); } export async function inspectAttachmentPaths( paths: string[], ): Promise { - return invoke("inspect_attachment_paths", { paths }); + const client = await getClient(); + const response = await client.goose.GooseSystemInspectAttachmentPaths({ + paths, + }); + return response.attachments.map((attachment) => ({ + name: attachment.name, + path: attachment.path, + kind: attachment.kind === "directory" ? "directory" : "file", + ...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}), + })); +} + +export async function listFilesForMentions( + roots: string[], + maxResults = 1500, +): Promise { + const client = await getClient(); + const response = await client.goose.GooseSystemListFilesForMentions({ + roots, + maxResults, + }); + return response.files; } export async function readImageAttachment( path: string, ): Promise { - return invoke("read_image_attachment", { path }); + const client = await getClient(); + const response = await client.goose.GooseSystemReadImageAttachment({ path }); + return { base64: response.base64, mimeType: response.mimeType }; +} + +/** + * Write a UTF-8 string to a path on disk, creating any missing parent + * directories. The desktop shell uses this to persist content the user has + * chosen via a native file dialog (e.g. exported session JSON). + */ +export async function writeFile(path: string, contents: string): Promise { + const client = await getClient(); + await client.goose.GooseSystemWriteFile({ path, contents }); } diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index e2bf21846b37..b7981e75b305 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -359,12 +359,6 @@ export function buildInitScript(options?: { return Promise.resolve("/tmp/avatars"); case "save_persona_avatar_bytes": return Promise.resolve("avatar.png"); - case "list_files_for_mentions": - return Promise.resolve([]); - case "get_home_dir": - return Promise.resolve("/tmp/home"); - case "path_exists": - return Promise.resolve(false); case "resolve_path": { const parts = args?.request?.parts ?? []; const path = parts diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 45ba4c26881d..ad70b5ecc287 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -35,6 +35,8 @@ import type { ExportSourceResponse, GetExtensionsRequest, GetExtensionsResponse, + GetHomeDirRequest, + GetHomeDirResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, @@ -43,12 +45,22 @@ import type { ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, + InspectAttachmentPathsRequest, + InspectAttachmentPathsResponse, + ListDirectoryEntriesRequest, + ListDirectoryEntriesResponse, + ListFilesForMentionsRequest, + ListFilesForMentionsResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, + PathExistsRequest, + PathExistsResponse, ReadConfigRequest, ReadConfigResponse, + ReadImageAttachmentRequest, + ReadImageAttachmentResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, @@ -66,6 +78,7 @@ import type { UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest, + WriteFileRequest, } from './types.gen.js'; import { zCheckSecretResponse, @@ -77,13 +90,19 @@ import { zExportSessionResponse, zExportSourceResponse, zGetExtensionsResponse, + zGetHomeDirResponse, zGetSessionExtensionsResponse, zGetToolsResponse, zImportSessionResponse, zImportSourcesResponse, + zInspectAttachmentPathsResponse, + zListDirectoryEntriesResponse, + zListFilesForMentionsResponse, zListProvidersResponse, zListSourcesResponse, + zPathExistsResponse, zReadConfigResponse, + zReadImageAttachmentResponse, zReadResourceResponse, zRefreshProviderInventoryResponse, zUpdateSourceResponse, @@ -340,4 +359,70 @@ export class GooseExtClient { ): Promise { await this.conn.extMethod("_goose/dictation/model/select", params); } + + async GooseSystemHomeDir( + params: GetHomeDirRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/system/home_dir", params); + return zGetHomeDirResponse.parse(raw) as GetHomeDirResponse; + } + + async GooseSystemPathExists( + params: PathExistsRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/system/path_exists", params); + return zPathExistsResponse.parse(raw) as PathExistsResponse; + } + + async GooseSystemListDirectoryEntries( + params: ListDirectoryEntriesRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/system/list_directory_entries", + params, + ); + return zListDirectoryEntriesResponse.parse( + raw, + ) as ListDirectoryEntriesResponse; + } + + async GooseSystemInspectAttachmentPaths( + params: InspectAttachmentPathsRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/system/inspect_attachment_paths", + params, + ); + return zInspectAttachmentPathsResponse.parse( + raw, + ) as InspectAttachmentPathsResponse; + } + + async GooseSystemListFilesForMentions( + params: ListFilesForMentionsRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/system/list_files_for_mentions", + params, + ); + return zListFilesForMentionsResponse.parse( + raw, + ) as ListFilesForMentionsResponse; + } + + async GooseSystemReadImageAttachment( + params: ReadImageAttachmentRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/system/read_image_attachment", + params, + ); + return zReadImageAttachmentResponse.parse( + raw, + ) as ReadImageAttachmentResponse; + } + + async GooseSystemWriteFile(params: WriteFileRequest): Promise { + await this.conn.extMethod("_goose/system/write_file", params); + } } diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 8220602d8352..4668cf3a684d 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest, 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, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ProviderConfigKey, ProviderInventoryEntryDto, ProviderInventoryModelDto, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddConfigExtensionRequest, AddExtensionRequest, ArchiveSessionRequest, AttachmentPathInfoDto, 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, FileTreeEntryDto, GetExtensionsRequest, GetExtensionsResponse, GetHomeDirRequest, GetHomeDirResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, InspectAttachmentPathsRequest, InspectAttachmentPathsResponse, ListDirectoryEntriesRequest, ListDirectoryEntriesResponse, ListFilesForMentionsRequest, ListFilesForMentionsResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, PathExistsRequest, PathExistsResponse, ProviderConfigKey, ProviderInventoryEntryDto, ProviderInventoryModelDto, ReadConfigRequest, ReadConfigResponse, ReadImageAttachmentRequest, ReadImageAttachmentResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest, WriteFileRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -198,6 +198,41 @@ export const GOOSE_EXT_METHODS = [ requestType: "DictationModelSelectRequest", responseType: "EmptyResponse", }, + { + method: "_goose/system/home_dir", + requestType: "GetHomeDirRequest", + responseType: "GetHomeDirResponse", + }, + { + method: "_goose/system/path_exists", + requestType: "PathExistsRequest", + responseType: "PathExistsResponse", + }, + { + method: "_goose/system/list_directory_entries", + requestType: "ListDirectoryEntriesRequest", + responseType: "ListDirectoryEntriesResponse", + }, + { + method: "_goose/system/inspect_attachment_paths", + requestType: "InspectAttachmentPathsRequest", + responseType: "InspectAttachmentPathsResponse", + }, + { + method: "_goose/system/list_files_for_mentions", + requestType: "ListFilesForMentionsRequest", + responseType: "ListFilesForMentionsResponse", + }, + { + method: "_goose/system/read_image_attachment", + requestType: "ReadImageAttachmentRequest", + responseType: "ReadImageAttachmentResponse", + }, + { + method: "_goose/system/write_file", + requestType: "WriteFileRequest", + responseType: "EmptyResponse", + }, ] as const; export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number]; diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 222f30e9fc34..c0051c26fd63 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -664,17 +664,136 @@ export type DictationModelSelectRequest = { modelId: string; }; +/** + * Resolve the current user's home directory. + */ +export type GetHomeDirRequest = { + [key: string]: unknown; +}; + +export type GetHomeDirResponse = { + /** + * Absolute path to the user's home directory. + */ + path: string; +}; + +/** + * Check whether a path exists on disk. + */ +export type PathExistsRequest = { + path: string; +}; + +export type PathExistsResponse = { + exists: boolean; +}; + +/** + * List the immediate children of a directory. + */ +export type ListDirectoryEntriesRequest = { + path: string; +}; + +export type ListDirectoryEntriesResponse = { + entries: Array; +}; + +/** + * A single filesystem entry surfaced to the desktop UI. + */ +export type FileTreeEntryDto = { + name: string; + path: string; + /** + * `"file"` or `"directory"`. + */ + kind: string; +}; + +/** + * Inspect a batch of attachment paths. Missing entries are silently skipped. + */ +export type InspectAttachmentPathsRequest = { + paths: Array; +}; + +export type InspectAttachmentPathsResponse = { + attachments: Array; +}; + +/** + * Metadata describing a single attachment path on disk. + */ +export type AttachmentPathInfoDto = { + name: string; + path: string; + /** + * `"file"` or `"directory"`. + */ + kind: string; + mimeType?: string | null; +}; + +/** + * Walk one or more roots and return a sorted list of file paths suitable for + * `@`-mention pickers. Honours `.gitignore`, hidden files, and symlink escapes. + */ +export type ListFilesForMentionsRequest = { + roots: Array; + /** + * Maximum number of results to return. Clamped to 1..=5000; defaults to 1500. + */ + maxResults?: number | null; +}; + +export type ListFilesForMentionsResponse = { + files: Array; +}; + +/** + * Read an image attachment from disk and return it as a base64 payload. + */ +export type ReadImageAttachmentRequest = { + path: string; +}; + +export type ReadImageAttachmentResponse = { + /** + * Base64-encoded image bytes. + */ + base64: string; + /** + * MIME type detected from the path's extension (always starts with `image/`). + */ + mimeType: string; +}; + +/** + * Write a UTF-8 string to a path on disk, creating any missing parents. + * + * The desktop shell uses this to persist content the user has chosen via a + * native file dialog (e.g. exported sessions). Tauri-backed file dialogs are + * still owned by the desktop shell; only the actual write is delegated to + * `goose serve`. + */ +export type WriteFileRequest = { + path: string; + contents: string; +}; + export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | GetHomeDirRequest | PathExistsRequest | ListDirectoryEntriesRequest | InspectAttachmentPathsRequest | ListFilesForMentionsRequest | ReadImageAttachmentRequest | WriteFileRequest | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; + result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | GetHomeDirResponse | PathExistsResponse | ListDirectoryEntriesResponse | InspectAttachmentPathsResponse | ListFilesForMentionsResponse | ReadImageAttachmentResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 3c7e1a34cef7..8c4472822bd0 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -610,6 +610,111 @@ export const zDictationModelSelectRequest = z.object({ modelId: z.string() }); +/** + * Resolve the current user's home directory. + */ +export const zGetHomeDirRequest = z.record(z.unknown()); + +export const zGetHomeDirResponse = z.object({ + path: z.string() +}); + +/** + * Check whether a path exists on disk. + */ +export const zPathExistsRequest = z.object({ + path: z.string() +}); + +export const zPathExistsResponse = z.object({ + exists: z.boolean() +}); + +/** + * List the immediate children of a directory. + */ +export const zListDirectoryEntriesRequest = z.object({ + path: z.string() +}); + +/** + * A single filesystem entry surfaced to the desktop UI. + */ +export const zFileTreeEntryDto = z.object({ + name: z.string(), + path: z.string(), + kind: z.string() +}); + +export const zListDirectoryEntriesResponse = z.object({ + entries: z.array(zFileTreeEntryDto) +}); + +/** + * Inspect a batch of attachment paths. Missing entries are silently skipped. + */ +export const zInspectAttachmentPathsRequest = z.object({ + paths: z.array(z.string()) +}); + +/** + * Metadata describing a single attachment path on disk. + */ +export const zAttachmentPathInfoDto = z.object({ + name: z.string(), + path: z.string(), + kind: z.string(), + mimeType: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zInspectAttachmentPathsResponse = z.object({ + attachments: z.array(zAttachmentPathInfoDto) +}); + +/** + * Walk one or more roots and return a sorted list of file paths suitable for + * `@`-mention pickers. Honours `.gitignore`, hidden files, and symlink escapes. + */ +export const zListFilesForMentionsRequest = z.object({ + roots: z.array(z.string()), + maxResults: z.union([ + z.number().int().gte(0).max(4294967295, { message: 'Invalid value: Expected uint32 to be <= 4294967295' }), + z.null() + ]).optional() +}); + +export const zListFilesForMentionsResponse = z.object({ + files: z.array(z.string()) +}); + +/** + * Read an image attachment from disk and return it as a base64 payload. + */ +export const zReadImageAttachmentRequest = z.object({ + path: z.string() +}); + +export const zReadImageAttachmentResponse = z.object({ + base64: z.string(), + mimeType: z.string() +}); + +/** + * Write a UTF-8 string to a path on disk, creating any missing parents. + * + * The desktop shell uses this to persist content the user has chosen via a + * native file dialog (e.g. exported sessions). Tauri-backed file dialogs are + * still owned by the desktop shell; only the actual write is delegated to + * `goose serve`. + */ +export const zWriteFileRequest = z.object({ + path: z.string(), + contents: z.string() +}); + export const zExtRequest = z.object({ id: z.string(), method: z.string(), @@ -653,7 +758,14 @@ export const zExtRequest = z.object({ zDictationModelDownloadProgressRequest, zDictationModelCancelRequest, zDictationModelDeleteRequest, - zDictationModelSelectRequest + zDictationModelSelectRequest, + zGetHomeDirRequest, + zPathExistsRequest, + zListDirectoryEntriesRequest, + zInspectAttachmentPathsRequest, + zListFilesForMentionsRequest, + zReadImageAttachmentRequest, + zWriteFileRequest ]), z.union([ z.record(z.unknown()), @@ -686,7 +798,13 @@ export const zExtResponse = z.union([ zDictationTranscribeResponse, zDictationConfigResponse, zDictationModelsListResponse, - zDictationModelDownloadProgressResponse + zDictationModelDownloadProgressResponse, + zGetHomeDirResponse, + zPathExistsResponse, + zListDirectoryEntriesResponse, + zInspectAttachmentPathsResponse, + zListFilesForMentionsResponse, + zReadImageAttachmentResponse ]), z.unknown() ]).optional()