Skip to content
Merged
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
32 changes: 31 additions & 1 deletion crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct Cli {
command: Option<Command>,
}

#[derive(Args)]
#[derive(Args, Debug)]
#[group(required = false, multiple = false)]
struct Identifier {
#[arg(
Expand Down Expand Up @@ -102,6 +102,19 @@ enum SessionCommand {
#[arg(short, long, help = "Regex for removing matched sessions (optional)")]
regex: Option<String>,
},
#[command(about = "Export a session to Markdown format")]
Export {
#[command(flatten)]
identifier: Option<Identifier>,

#[arg(
short,
long,
help = "Output file path (default: stdout)",
long_help = "Path to save the exported Markdown. If not provided, output will be sent to stdout"
)]
output: Option<PathBuf>,
},
}

#[derive(Subcommand, Debug)]
Expand Down Expand Up @@ -550,6 +563,23 @@ pub async fn cli() -> Result<()> {
handle_session_remove(id, regex)?;
return Ok(());
}
Some(SessionCommand::Export { identifier, output }) => {
let session_identifier = if let Some(id) = identifier {
extract_identifier(id)
} else {
// If no identifier is provided, prompt for interactive selection
match crate::commands::session::prompt_interactive_session_selection() {
Ok(id) => id,
Err(e) => {
eprintln!("Error: {}", e);
return Ok(());
}
}
};

crate::commands::session::handle_session_export(session_identifier, output)?;
Ok(())
}
None => {
// Run session command by default
let mut session: crate::Session = build_session(SessionBuilderConfig {
Expand Down
190 changes: 187 additions & 3 deletions crates/goose-cli/src/commands/session.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use crate::session::message_to_markdown;
use anyhow::{Context, Result};
use cliclack::{confirm, multiselect};
use cliclack::{confirm, multiselect, select};
use goose::session::info::{get_session_info, SessionInfo, SortOrder};
use goose::session::{self, Identifier};
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};

const TRUNCATED_DESC_LENGTH: usize = 60;

Expand All @@ -29,7 +32,7 @@ pub fn remove_sessions(sessions: Vec<SessionInfo>) -> Result<()> {
Ok(())
}

fn prompt_interactive_session_selection(sessions: &[SessionInfo]) -> Result<Vec<SessionInfo>> {
fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result<Vec<SessionInfo>> {
if sessions.is_empty() {
println!("No sessions to delete.");
return Ok(vec![]);
Expand Down Expand Up @@ -105,7 +108,7 @@ pub fn handle_session_remove(id: Option<String>, regex_string: Option<String>) -
if all_sessions.is_empty() {
return Err(anyhow::anyhow!("No sessions found."));
}
matched_sessions = prompt_interactive_session_selection(&all_sessions)?;
matched_sessions = prompt_interactive_session_removal(&all_sessions)?;
}

if matched_sessions.is_empty() {
Expand Down Expand Up @@ -165,3 +168,184 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re
}
Ok(())
}

/// Export a session to Markdown without creating a full Session object
///
/// This function directly reads messages from the session file and converts them to Markdown
/// without creating an Agent or prompting about working directories.
pub fn handle_session_export(identifier: Identifier, output_path: Option<PathBuf>) -> Result<()> {
// Get the session file path
let session_file_path = goose::session::get_path(identifier.clone());

if !session_file_path.exists() {
return Err(anyhow::anyhow!(
"Session file not found (expected path: {})",
session_file_path.display()
));
}

// Read messages directly without using Session
let messages = match goose::session::read_messages(&session_file_path) {
Ok(msgs) => msgs,
Err(e) => {
return Err(anyhow::anyhow!("Failed to read session messages: {}", e));
}
};

// Generate the markdown content using the export functionality
let markdown = export_session_to_markdown(messages, &session_file_path, None);

// Output the markdown
if let Some(output) = output_path {
fs::write(&output, markdown)
.with_context(|| format!("Failed to write to output file: {}", output.display()))?;
println!("Session exported to {}", output.display());
} else {
println!("{}", markdown);
}

Ok(())
}

/// Convert a list of messages to markdown format for session export
///
/// This function handles the formatting of a complete session including headers,
/// message organization, and proper tool request/response pairing.
fn export_session_to_markdown(
messages: Vec<goose::message::Message>,
session_file: &Path,
session_name_override: Option<&str>,
) -> String {
let mut markdown_output = String::new();

let session_name = session_name_override.unwrap_or_else(|| {
session_file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unnamed Session")
});

markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name));

if messages.is_empty() {
markdown_output.push_str("*(This session has no messages)*\n");
return markdown_output;
}

markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len()));

// Track if the last message had tool requests to properly handle tool responses
let mut skip_next_if_tool_response = false;

for message in &messages {
// Check if this is a User message containing only ToolResponses
let is_only_tool_response = message.role == mcp_core::role::Role::User
&& message
.content
.iter()
.all(|content| matches!(content, goose::message::MessageContent::ToolResponse(_)));

// If the previous message had tool requests and this one is just tool responses,
// don't create a new User section - we'll attach the responses to the tool calls
if skip_next_if_tool_response && is_only_tool_response {
// Export the tool responses without a User heading
markdown_output.push_str(&message_to_markdown(message, false));
markdown_output.push_str("\n\n---\n\n");
skip_next_if_tool_response = false;
continue;
}

// Reset the skip flag - we'll update it below if needed
skip_next_if_tool_response = false;

// Output the role prefix except for tool response-only messages
if !is_only_tool_response {
let role_prefix = match message.role {
mcp_core::role::Role::User => "### User:\n",
mcp_core::role::Role::Assistant => "### Assistant:\n",
};
markdown_output.push_str(role_prefix);
}

// Add the message content
markdown_output.push_str(&message_to_markdown(message, false));
markdown_output.push_str("\n\n---\n\n");

// Check if this message has any tool requests, to handle the next message differently
if message
.content
.iter()
.any(|content| matches!(content, goose::message::MessageContent::ToolRequest(_)))
{
skip_next_if_tool_response = true;
}
}

markdown_output
}

/// Prompt the user to interactively select a session
///
/// Shows a list of available sessions and lets the user select one
pub fn prompt_interactive_session_selection() -> Result<session::Identifier> {
// Get sessions sorted by modification date (newest first)
let sessions = match get_session_info(SortOrder::Descending) {
Ok(sessions) => sessions,
Err(e) => {
tracing::error!("Failed to list sessions: {:?}", e);
return Err(anyhow::anyhow!("Failed to list sessions"));
}
};

if sessions.is_empty() {
return Err(anyhow::anyhow!("No sessions found"));
}

// Build the selection prompt
let mut selector = select("Select a session to export:");

// Map to display text
let display_map: std::collections::HashMap<String, SessionInfo> = sessions
.iter()
.map(|s| {
let desc = if s.metadata.description.is_empty() {
"(no description)"
} else {
&s.metadata.description
};

// Truncate description if too long
let truncated_desc = if desc.len() > 40 {
format!("{}...", &desc[..37])
} else {
desc.to_string()
};

let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id);
(display_text, s.clone())
})
.collect();

// Add each session as an option
for display_text in display_map.keys() {
selector = selector.item(display_text.clone(), display_text.clone(), "");
}

// Add a cancel option
let cancel_value = String::from("cancel");
selector = selector.item(cancel_value, "Cancel", "Cancel export");

// Get user selection
let selected_display_text: String = selector.interact()?;

if selected_display_text == "cancel" {
return Err(anyhow::anyhow!("Export canceled"));
}

// Retrieve the selected session
if let Some(session) = display_map.get(&selected_display_text) {
Ok(goose::session::Identifier::Name(session.id.clone()))
} else {
Err(anyhow::anyhow!("Invalid selection"))
}
}
Loading
Loading