mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-19 15:14: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>,
|
command: Option<Command>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args, Debug)]
|
||||||
#[group(required = false, multiple = false)]
|
#[group(required = false, multiple = false)]
|
||||||
struct Identifier {
|
struct Identifier {
|
||||||
#[arg(
|
#[arg(
|
||||||
@@ -102,6 +102,19 @@ enum SessionCommand {
|
|||||||
#[arg(short, long, help = "Regex for removing matched sessions (optional)")]
|
#[arg(short, long, help = "Regex for removing matched sessions (optional)")]
|
||||||
regex: Option<String>,
|
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)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -550,6 +563,23 @@ pub async fn cli() -> Result<()> {
|
|||||||
handle_session_remove(id, regex)?;
|
handle_session_remove(id, regex)?;
|
||||||
return Ok(());
|
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 => {
|
None => {
|
||||||
// Run session command by default
|
// Run session command by default
|
||||||
let mut session: crate::Session = build_session(SessionBuilderConfig {
|
let mut session: crate::Session = build_session(SessionBuilderConfig {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
use crate::session::message_to_markdown;
|
||||||
use anyhow::{Context, Result};
|
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::info::{get_session_info, SessionInfo, SortOrder};
|
||||||
|
use goose::session::{self, Identifier};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const TRUNCATED_DESC_LENGTH: usize = 60;
|
const TRUNCATED_DESC_LENGTH: usize = 60;
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ pub fn remove_sessions(sessions: Vec<SessionInfo>) -> Result<()> {
|
|||||||
Ok(())
|
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() {
|
if sessions.is_empty() {
|
||||||
println!("No sessions to delete.");
|
println!("No sessions to delete.");
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
@@ -105,7 +108,7 @@ pub fn handle_session_remove(id: Option<String>, regex_string: Option<String>) -
|
|||||||
if all_sessions.is_empty() {
|
if all_sessions.is_empty() {
|
||||||
return Err(anyhow::anyhow!("No sessions found."));
|
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() {
|
if matched_sessions.is_empty() {
|
||||||
@@ -165,3 +168,184 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re
|
|||||||
}
|
}
|
||||||
Ok(())
|
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 builder;
|
||||||
mod completion;
|
mod completion;
|
||||||
|
mod export;
|
||||||
mod input;
|
mod input;
|
||||||
mod output;
|
mod output;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod thinking;
|
mod thinking;
|
||||||
|
|
||||||
|
pub use self::export::message_to_markdown;
|
||||||
pub use builder::{build_session, SessionBuilderConfig};
|
pub use builder::{build_session, SessionBuilderConfig};
|
||||||
use console::Color;
|
use console::Color;
|
||||||
use goose::agents::AgentEvent;
|
use goose::agents::AgentEvent;
|
||||||
|
|||||||
Reference in New Issue
Block a user