Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 363 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

101 changes: 99 additions & 2 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,70 @@ pub struct Cli {
command: Option<Command>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_session_export_nostr_relays() {
let cli = Cli::try_parse_from([
"goose",
"session",
"export",
"--session-id",
"session-id",
"--format",
"json",
"--nostr",
"--relay",
"wss://relay.one",
"--relay",
"wss://relay.two",
])
.unwrap();

match cli.command {
Some(Command::Session {
command:
Some(SessionCommand::Export {
identifier,
format,
nostr,
relays,
..
}),
..
}) => {
assert_eq!(
identifier.unwrap().session_id.as_deref(),
Some("session-id")
);
assert_eq!(format, "json");
assert!(nostr);
assert_eq!(relays, vec!["wss://relay.one", "wss://relay.two"]);
}
_ => panic!("expected session export command"),
}
}

#[test]
fn parses_session_import_nostr_link() {
let deeplink = "goose://sessions/nostr?nevent=nevent1abc&key=secret";
let cli = Cli::try_parse_from(["goose", "session", "import", "--nostr", deeplink]).unwrap();

match cli.command {
Some(Command::Session {
command: Some(SessionCommand::Import { input, nostr }),
..
}) => {
assert_eq!(input, deeplink);
assert!(nostr);
}
_ => panic!("expected session import command"),
}
}
}

#[derive(Args, Debug, Clone)]
#[group(required = false, multiple = false)]
pub struct Identifier {
Expand Down Expand Up @@ -529,6 +593,28 @@ enum SessionCommand {
default_value = "markdown"
)]
format: String,

#[arg(
long = "nostr",
help = "Publish the JSON session export as an encrypted Nostr event and print a Goose share link"
)]
nostr: bool,

#[arg(
long = "relay",
value_name = "RELAY",
help = "Nostr relay URL to publish to (can be specified multiple times)",
action = clap::ArgAction::Append
)]
relays: Vec<String>,
},
#[command(about = "Import a session from JSON or an encrypted Nostr share link")]
Import {
#[arg(help = "Path to a JSON session export, or a goose://sessions/nostr share link")]
input: String,

#[arg(long = "nostr", help = "Treat input as an encrypted Nostr share link")]
nostr: bool,
},
#[command(name = "diagnostics")]
Diagnostics {
Expand Down Expand Up @@ -1119,6 +1205,8 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
identifier,
output,
format,
nostr,
relays,
} => {
let session_manager = SessionManager::instance();
let session_identifier = if let Some(id) = identifier {
Expand All @@ -1136,8 +1224,17 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
}
}
};
crate::commands::session::handle_session_export(session_identifier, output, format)
.await?;
crate::commands::session::handle_session_export(
session_identifier,
output,
format,
nostr,
relays,
)
.await?;
}
SessionCommand::Import { input, nostr } => {
crate::commands::session::handle_session_import(input, nostr).await?;
}
SessionCommand::Diagnostics { identifier, output } => {
let session_manager = SessionManager::instance();
Expand Down
47 changes: 46 additions & 1 deletion crates/goose-cli/src/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use anyhow::{Context, Result};

use cliclack::{confirm, multiselect, select};
use etcetera::home_dir;
use goose::session::{generate_diagnostics, Session, SessionManager};
use goose::config::Config;
use goose::session::{generate_diagnostics, nostr_share, Session, SessionManager, SessionType};
use goose::utils::safe_truncate;
use regex::Regex;
use std::fs;
Expand Down Expand Up @@ -216,6 +217,8 @@ pub async fn handle_session_export(
session_id: String,
output_path: Option<PathBuf>,
format: String,
nostr: bool,
relays: Vec<String>,
) -> Result<()> {
let session_manager = SessionManager::instance();
let session = match session_manager.get_session(&session_id, true).await {
Expand All @@ -241,6 +244,29 @@ pub async fn handle_session_export(
_ => return Err(anyhow::anyhow!("Unsupported format: {}", format)),
};

if nostr {
if format != "json" {
return Err(anyhow::anyhow!(
"Nostr session sharing only supports --format json"
));
}
if output_path.is_some() {
return Err(anyhow::anyhow!(
"Nostr session sharing cannot be combined with --output"
));
}

let relays = nostr_share::resolve_relays(relays, Config::global());
let share = nostr_share::publish_session_json(&output, relays).await?;
println!("Session published to Nostr relays:");
for relay in &share.relays {
println!("- {}", relay);
}
println!("\nShare link:");
println!("{}", share.deeplink);
return Ok(());
}

if let Some(output_path) = output_path {
fs::write(&output_path, output).with_context(|| {
format!("Failed to write to output file: {}", output_path.display())
Expand All @@ -253,6 +279,25 @@ pub async fn handle_session_export(
Ok(())
}

pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> {
let json = if nostr || input.starts_with("goose://sessions/nostr") {
nostr_share::import_session_json_from_deeplink(&input).await?
} else {
fs::read_to_string(&input)
.with_context(|| format!("Failed to read session import file: {input}"))?
};

let session_manager = SessionManager::instance();
let session = session_manager
.import_session(&json, Some(SessionType::User))
.await?;

println!("Session imported:");
println!("{} - {}", session.id, session.name);

Ok(())
}

pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>) -> Result<()> {
println!(
"Generating diagnostics bundle for session '{}'...",
Expand Down
5 changes: 5 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,8 @@ derive_utoipa!(IconTheme as IconThemeSchema);
super::routes::session::delete_session,
super::routes::session::export_session,
super::routes::session::import_session,
super::routes::session::share_session_nostr,
super::routes::session::import_session_nostr,
super::routes::session::update_session_user_recipe_values,
super::routes::session::fork_session,
super::routes::session::get_session_extensions,
Expand Down Expand Up @@ -512,6 +514,9 @@ derive_utoipa!(IconTheme as IconThemeSchema);
super::routes::session_events::SessionReplyResponse,
super::routes::session_events::CancelRequest,
super::routes::session::ImportSessionRequest,
super::routes::session::ShareSessionNostrRequest,
super::routes::session::ShareSessionNostrResponse,
super::routes::session::ImportSessionNostrRequest,
super::routes::session::SessionListResponse,
super::routes::session::UpdateSessionNameRequest,
super::routes::session::UpdateSessionUserRecipeValuesRequest,
Expand Down
104 changes: 104 additions & 0 deletions crates/goose-server/src/routes/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use axum::{
};
use goose::agents::ExtensionConfig;
use goose::recipe::Recipe;
use goose::session::nostr_share;
use goose::session::session_manager::{SessionInsights, SessionType};
use goose::session::{EnabledExtensionsState, Session};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -50,6 +51,27 @@ pub struct ImportSessionRequest {
json: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ShareSessionNostrRequest {
relays: Option<Vec<String>>,
}

#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ShareSessionNostrResponse {
deeplink: String,
nevent: String,
event_id: String,
relays: Vec<String>,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportSessionNostrRequest {
deeplink: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ForkRequest {
Expand Down Expand Up @@ -364,6 +386,80 @@ async fn import_session(
Ok(Json(session))
}

#[utoipa::path(
post,
path = "/sessions/{session_id}/share/nostr",
request_body = ShareSessionNostrRequest,
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Session shared to Nostr successfully", body = ShareSessionNostrResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn share_session_nostr(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(request): Json<ShareSessionNostrRequest>,
) -> Result<Json<ShareSessionNostrResponse>, StatusCode> {
let exported = state
.session_manager()
.export_session(&session_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;

let relays = request.relays.unwrap_or_default();
let relays = nostr_share::resolve_relays(relays, goose::config::Config::global());
let share = nostr_share::publish_session_json(&exported, relays)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

Ok(Json(ShareSessionNostrResponse {
deeplink: share.deeplink,
nevent: share.nevent,
event_id: share.event_id,
relays: share.relays,
}))
}

#[utoipa::path(
post,
path = "/sessions/import/nostr",
request_body = ImportSessionNostrRequest,
responses(
(status = 200, description = "Nostr shared session imported successfully", body = Session),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 400, description = "Bad request - Invalid Nostr share link"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn import_session_nostr(
State(state): State<Arc<AppState>>,
Json(request): Json<ImportSessionNostrRequest>,
) -> Result<Json<Session>, StatusCode> {
let json = nostr_share::import_session_json_from_deeplink(&request.deeplink)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
let session = state
.session_manager()
.import_session(&json, Some(SessionType::User))
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;

Ok(Json(session))
}

#[utoipa::path(
post,
path = "/sessions/{session_id}/fork",
Expand Down Expand Up @@ -505,10 +601,18 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/sessions/{session_id}", get(get_session))
.route("/sessions/{session_id}", delete(delete_session))
.route("/sessions/{session_id}/export", get(export_session))
.route(
"/sessions/{session_id}/share/nostr",
post(share_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route(
"/sessions/import",
post(import_session).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route(
"/sessions/import/nostr",
post(import_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route("/sessions/insights", get(get_session_insights))
.route("/sessions/{session_id}/name", put(update_session_name))
.route(
Expand Down
5 changes: 5 additions & 0 deletions crates/goose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ aws-providers = [
]
cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
rustls-tls = [
"dep:rustls",
"reqwest/rustls",
"rmcp/reqwest",
"sqlx/runtime-tokio-rustls",
Expand All @@ -57,6 +58,7 @@ native-tls = [
"oauth2/reqwest",
"oauth2/native-tls",
]
rustls = ["dep:rustls"]

[lints]
workspace = true
Expand Down Expand Up @@ -194,6 +196,9 @@ goose-acp-macros = { path = "../goose-acp-macros" }
tower-http = { workspace = true, features = ["cors"] }
http-body-util = "0.1.3"
process-wrap = { version = "9.1.0", features = ["std"] }
nostr = { version = "0.44.2", features = ["nip44"] }
nostr-sdk = { version = "0.44.1", features = ["nip44"] }
rustls = { version = "0.23", features = ["aws_lc_rs"], optional = true }


[target.'cfg(target_os = "windows")'.dependencies]
Expand Down
1 change: 1 addition & 0 deletions crates/goose/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod chat_history_search;
mod diagnostics;
pub mod extension_data;
mod legacy;
pub mod nostr_share;
pub mod session_manager;
pub mod thread_manager;

Expand Down
Loading
Loading