mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-18 14:44:21 +01:00
cli(command): Add export command to CLI for markdown export of sessions (#2533)
This commit is contained in:
committed by
GitHub
parent
465a43cf51
commit
d3359a12a4
@@ -34,7 +34,7 @@ struct Cli {
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
#[derive(Args, Debug)]
|
||||
#[group(required = false, multiple = false)]
|
||||
struct Identifier {
|
||||
#[arg(
|
||||
@@ -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)]
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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![]);
|
||||
@@ -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() {
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
1095
crates/goose-cli/src/session/export.rs
Normal file
1095
crates/goose-cli/src/session/export.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
mod builder;
|
||||
mod completion;
|
||||
mod export;
|
||||
mod input;
|
||||
mod output;
|
||||
mod prompt;
|
||||
mod thinking;
|
||||
|
||||
pub use self::export::message_to_markdown;
|
||||
pub use builder::{build_session, SessionBuilderConfig};
|
||||
use console::Color;
|
||||
use goose::agents::AgentEvent;
|
||||
|
||||
Reference in New Issue
Block a user