use core::fmt::Write as FmtWrite;
use unilang::{ VerifiedCommand, ExecutionContext, OutputData, ErrorData, ErrorCode };
use claude_storage_core::Storage;
fn create_storage() -> core::result::Result< Storage, ErrorData >
{
match std::env::var( "CLAUDE_STORAGE_ROOT" )
{
Ok( root ) if !root.is_empty() =>
Ok( Storage::with_root( std::path::Path::new( &root ) ) ),
_ =>
Storage::new()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to create storage: {e}" ) ) ),
}
}
#[allow(clippy::needless_pass_by_value)]
#[inline]
pub fn status_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
if !( 0..=5 ).contains( &verbosity )
{
return Err
(
ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid verbosity: {verbosity}. Valid range: 0-5" )
)
);
}
let custom_path = cmd.get_string( "path" );
let resolved_path = custom_path
.map( | path | resolve_path_parameter( path )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to resolve path '{path}': {e}" ) ) )
)
.transpose()?;
let storage = if let Some( path ) = resolved_path
{
Storage::with_root( &path )
}
else
{
create_storage()?
};
let output = if verbosity <= 1
{
let stats = storage.global_stats_fast()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to get statistics: {e}" ) ) )?;
match verbosity
{
0 =>
{
format!( "Projects: {}", stats.total_projects )
}
_ =>
{
format!
(
"Storage: {}\nProjects: {} (UUID: {}, Path: {})\nSessions: {} (Main: {}, Agent: {})",
storage.root().display(),
stats.total_projects,
stats.uuid_projects,
stats.path_projects,
stats.total_sessions,
stats.main_sessions,
stats.agent_sessions,
)
}
}
}
else
{
let stats = storage.global_stats()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to get statistics: {e}" ) ) )?;
format!
(
"Storage: {}\n\
Projects: {} (UUID: {}, Path: {})\n\
Sessions: {} (Main: {}, Agent: {})\n\
Entries: {} (User: {}, Assistant: {})\n\
Tokens:\n\
- Input: {}\n\
- Output: {}\n\
- Cache Read: {}\n\
- Cache Creation: {}",
storage.root().display(),
stats.total_projects,
stats.uuid_projects,
stats.path_projects,
stats.total_sessions,
stats.main_sessions,
stats.agent_sessions,
stats.total_entries,
stats.total_user_entries,
stats.total_assistant_entries,
stats.total_input_tokens,
stats.total_output_tokens,
stats.total_cache_read_tokens,
stats.total_cache_creation_tokens
)
};
Ok( OutputData::new( output, "text" ) )
}
fn resolve_path_parameter( param : &str ) -> core::result::Result< String, String >
{
use std::path::Path;
match param
{
"." =>
{
std::env::current_dir()
.map( | p | p.to_string_lossy().to_string() )
.map_err( | e | format!( "Failed to get current directory: {e}" ) )
},
".." =>
{
let current = std::env::current_dir()
.map_err( | e | format!( "Failed to get current directory: {e}" ) )?;
let parent = current.parent()
.ok_or_else( || "Current directory has no parent".to_string() )?;
Ok( parent.to_string_lossy().to_string() )
},
s if s.starts_with( '~' ) =>
{
let home = std::env::var( "HOME" )
.map_err( | e | format!( "Failed to get HOME directory: {e}" ) )?;
if s.len() == 1
{
Ok( home )
}
else if let Some( stripped ) = s.strip_prefix( "~/" )
{
let path = Path::new( &home ).join( stripped );
Ok( path.to_string_lossy().to_string() )
}
else
{
Ok( s.to_string() )
}
},
s if s.starts_with( '/' ) =>
{
Ok( s.to_string() )
},
s if s.contains( '/' ) =>
{
let current = std::env::current_dir()
.map_err( | e | format!( "Failed to get current directory: {e}" ) )?;
let resolved = current.join( s );
Ok( resolved.to_string_lossy().to_string() )
},
s =>
{
Ok( s.to_string() )
},
}
}
fn format_entry_content( entry : &claude_storage_core::Entry, max_length : Option< usize > ) -> String
{
use claude_storage_core::{ MessageContent, ContentBlock };
let timestamp = format_timestamp( &entry.timestamp );
let ( role, content ) = match &entry.message
{
MessageContent::User( msg ) =>
{
( "User", msg.content.clone() )
},
MessageContent::Assistant( msg ) =>
{
let text_blocks : Vec< String > = msg.content
.iter()
.filter_map( | block | match block
{
ContentBlock::Text { text } => Some( text.clone() ),
ContentBlock::Thinking { thinking, .. } =>
{
Some( format!( "[Thinking]\n{thinking}" ) )
},
ContentBlock::ToolUse { name, .. } =>
{
Some( format!( "[Using tool: {name}]" ) )
},
ContentBlock::ToolResult { is_error, content, .. } =>
{
if *is_error
{
Some( format!( "[Tool error: {content}]" ) )
}
else
{
None
}
},
})
.collect();
let combined = text_blocks.join( "\n\n" );
( "Assistant", combined )
}
};
let content = truncate_if_needed( &content, max_length );
format!( "[{timestamp}] {role}:\n{content}" )
}
fn format_timestamp( timestamp : &str ) -> String
{
if let Some( datetime_part ) = timestamp.split( '.' ).next()
{
if let Some( ( date, time ) ) = datetime_part.split_once( 'T' )
{
let time_short = time.split( ':' ).take( 2 ).collect::< Vec< _ > >().join( ":" );
return format!( "{date} {time_short}" );
}
}
timestamp.to_string()
}
#[must_use]
#[inline]
pub fn truncate_if_needed( text : &str, max_length : Option< usize > ) -> String
{
match max_length
{
None => text.to_string(),
Some( len ) if text.len() <= len => text.to_string(),
Some( len ) =>
{
let mut end = len;
while end > 0 && !text.is_char_boundary( end )
{
end -= 1;
}
let truncated = &text[ ..end ];
format!( "{}... [truncated, {} more bytes]", truncated, text.len() - end )
}
}
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::needless_pass_by_value)]
#[inline]
pub fn list_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let project_type = cmd.get_string( "type" ).unwrap_or( "all" );
let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
if !( 0..=5 ).contains( &verbosity )
{
return Err
(
ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid verbosity: {verbosity}. Valid range: 0-5" )
)
);
}
let path_filter = cmd.get_string( "path" );
let agent_filter = cmd.get_boolean( "agent" );
let min_entries_filter = if let Some( n ) = cmd.get_integer( "min_entries" )
{
if n < 0
{
return Err
(
ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid min_entries: {n}. Must be non-negative" )
)
);
}
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
Some( n as usize )
}
else
{
None
};
let session_id_filter = cmd.get_string( "session" );
let path_filter = if let Some( param ) = path_filter
{
match resolve_path_parameter( param )
{
Ok( resolved ) => Some( resolved ),
Err( e ) =>
{
return Err
(
ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to resolve path parameter '{}': {}", ¶m, e )
)
);
}
}
}
else
{
None
};
let has_session_filters = session_id_filter.is_some()
|| agent_filter.is_some()
|| min_entries_filter.is_some();
let show_sessions = has_session_filters || cmd.get_boolean( "sessions" ).unwrap_or( false );
let storage = create_storage()?;
let project_filter = claude_storage_core::ProjectFilter
{
path_substring : path_filter,
min_entries : None,
min_sessions : None,
};
let session_filter = claude_storage_core::SessionFilter
{
agent_only : agent_filter,
min_entries : min_entries_filter,
session_id_substring : session_id_filter.map( std::string::ToString::to_string ),
};
let mut projects = match project_type
{
"uuid" => storage.list_uuid_projects(),
"path" => storage.list_path_projects(),
"all" => storage.list_projects(),
_ => return Err
(
ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid type: {project_type}. Valid values: uuid, path, all" )
)
),
}
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list projects: {e}" ) ) )?;
if !project_filter.is_default()
{
projects.retain( | project |
{
project.matches_filter( &project_filter ).unwrap_or( false )
});
}
let mut output = String::new();
if verbosity >= 1
{
let noun = if projects.len() == 1 { "project" } else { "projects" };
writeln!( output, "Found {} {noun}:\n", projects.len() ).unwrap();
}
for mut project in projects
{
match verbosity
{
0 =>
{
writeln!( output, "{:?}", project.id() ).unwrap();
}
1 =>
{
let Ok( session_count ) = project.count_sessions() else { continue };
let noun = if session_count == 1 { "session" } else { "sessions" };
writeln!( output, "{:?} ({session_count} {noun})", project.id() ).unwrap();
}
_ =>
{
let Ok( project_stats ) = project.project_stats() else { continue };
write!
(
output,
"{:?}\n Sessions: {} (Main: {}, Agent: {})\n Entries: {}\n Tokens: {} in, {} out\n\n",
project.id(),
project_stats.session_count,
project_stats.main_session_count,
project_stats.agent_session_count,
project_stats.total_entries,
project_stats.total_input_tokens,
project_stats.total_output_tokens
).unwrap();
}
}
if show_sessions
{
let sessions = if session_filter.is_default()
{
match project.sessions()
{
Ok( s ) => s,
Err( _ ) => continue, }
}
else
{
match project.sessions_filtered( &session_filter )
{
Ok( s ) => s,
Err( _ ) => continue, }
};
for session in sessions
{
writeln!( output, " - {}", session.id() ).unwrap();
}
}
}
Ok( OutputData::new( output, "text" ) )
}
#[inline]
pub fn parse_project_parameter( input : &str )
-> core::result::Result< claude_storage_core::ProjectId, String >
{
use claude_storage_core::{ ProjectId, decode_path };
use std::path::PathBuf;
if let Some( path_str ) = input.strip_prefix( "Path(\"" ).and_then( | s | s.strip_suffix( "\")" ) )
{
return Ok( ProjectId::path( path_str ) );
}
let path = PathBuf::from( input );
if path.is_absolute()
{
return Ok( ProjectId::path( input ) );
}
if input.starts_with( '-' )
{
match decode_path( input )
{
Ok( decoded ) => return Ok( ProjectId::path( decoded ) ),
Err( e ) => return Err( format!( "Failed to decode path: {e}" ) ),
}
}
if input == "~" || input.starts_with( "~/" )
{
let home = std::env::var( "HOME" )
.map_err( | _ | "HOME environment variable not set".to_string() )?;
let expanded = if input == "~"
{
home
}
else
{
format!( "{}{}", home, &input[ 1.. ] )
};
return Ok( ProjectId::path( expanded ) );
}
if input == "." || input == ".." ||
input.starts_with( "./" ) || input.starts_with( "../" )
{
let cwd = std::env::current_dir()
.map_err( | e | format!( "Failed to get current directory: {e}" ) )?;
let path = cwd.join( input );
if input == "." || input == ".."
{
match path.canonicalize()
{
Ok( abs_path ) => return Ok( ProjectId::path( abs_path.to_string_lossy().to_string() ) ),
Err( e ) => return Err( format!( "Failed to resolve path '{input}': {e}" ) ),
}
}
use std::path::Component;
let mut normalized = PathBuf::new();
for component in path.components()
{
match component
{
Component::ParentDir =>
{
normalized.pop();
}
Component::CurDir =>
{
}
_ => normalized.push( component ),
}
}
return Ok( ProjectId::path( normalized.to_string_lossy().to_string() ) );
}
Ok( ProjectId::uuid( input ) )
}
#[allow(clippy::needless_pass_by_value)]
#[inline]
pub fn show_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let session_id = cmd.get_string( "session_id" );
let project_param = cmd.get_string( "project" );
let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
let show_entries = cmd.get_boolean( "entries" ).unwrap_or( false );
let metadata_only = cmd.get_boolean( "metadata" ).unwrap_or( false );
if !( 0..=5 ).contains( &verbosity )
{
return Err
(
ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid verbosity: {verbosity}. Valid range: 0-5" )
)
);
}
if show_entries && session_id.is_none()
{
return Err
(
ErrorData::new
(
ErrorCode::InternalError,
"Parameter 'entries' requires 'session_id'. \
Use '.show session_id::<id> entries::1' to display session entries."
.to_string()
)
);
}
match ( session_id, project_param )
{
( None, None ) =>
{
show_project_for_cwd_impl( verbosity )
}
( Some( sid ), None ) =>
{
show_session_in_cwd_impl( sid, verbosity, show_entries, metadata_only )
}
( None, Some( proj ) ) =>
{
show_project_impl( proj, verbosity )
}
( Some( sid ), Some( proj ) ) =>
{
show_session_in_project_impl( sid, proj, verbosity, show_entries, metadata_only )
}
}
}
fn show_session_in_cwd_impl(
session_id : &str,
verbosity : i64,
show_entries : bool,
metadata_only : bool
) -> core::result::Result< OutputData, ErrorData >
{
let storage = create_storage()?;
let project = storage.load_project_for_cwd()
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to load project from current directory: {e}" )
))?;
format_session_output( &project, session_id, verbosity, show_entries, metadata_only )
}
fn show_session_in_project_impl(
session_id : &str,
project_param : &str,
verbosity : i64,
show_entries : bool,
metadata_only : bool
) -> core::result::Result< OutputData, ErrorData >
{
let storage = create_storage()?;
let proj_id = parse_project_parameter( project_param )
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid project parameter: {e}" )
))?;
let project = storage.load_project( &proj_id )
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to load project {proj_id:?}: {e}" )
))?;
format_session_output( &project, session_id, verbosity, show_entries, metadata_only )
}
fn show_project_for_cwd_impl( verbosity : i64 )
-> core::result::Result< OutputData, ErrorData >
{
let storage = create_storage()?;
let project = storage.load_project_for_cwd()
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to load project from current directory: {e}" )
))?;
format_project_output( &project, verbosity )
}
fn show_project_impl( project_param : &str, verbosity : i64 )
-> core::result::Result< OutputData, ErrorData >
{
let storage = create_storage()?;
let proj_id = parse_project_parameter( project_param )
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid project parameter: {e}" )
))?;
let project = storage.load_project( &proj_id )
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to load project {proj_id:?}: {e}" )
))?;
format_project_output( &project, verbosity )
}
fn format_session_output(
project : &claude_storage_core::Project,
session_id : &str,
verbosity : i64,
show_entries : bool,
metadata_only : bool
) -> core::result::Result< OutputData, ErrorData >
{
let mut sessions = project.all_sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
let session = sessions.iter_mut()
.find( | s | s.id() == session_id || s.id().starts_with( session_id ) )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, format!( "Session not found: {session_id}" ) ) )?;
let stats = session.stats()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to get session stats: {e}" ) ) )?;
let mut output = String::new();
let show_content = verbosity >= 1 && !metadata_only;
let entry_noun = if stats.total_entries == 1 { "entry" } else { "entries" };
writeln!( output, "Session: {} ({} {entry_noun})", session_id, stats.total_entries ).unwrap();
if metadata_only || verbosity == 0
{
writeln!( output, "Path: {}", session.storage_path().display() ).unwrap();
writeln!( output, "Agent Session: {}", stats.is_agent_session ).unwrap();
writeln!( output, "Total Entries: {}", stats.total_entries ).unwrap();
writeln!( output, "User Entries: {}", stats.user_entries ).unwrap();
writeln!( output, "Assistant Entries: {}", stats.assistant_entries ).unwrap();
if let Some( first ) = &stats.first_timestamp
{
writeln!( output, "First Entry: {first}" ).unwrap();
}
if let Some( last ) = &stats.last_timestamp
{
writeln!( output, "Last Entry: {last}" ).unwrap();
}
if verbosity >= 2
{
output.push_str( "\nToken Usage:\n" );
writeln!( output, "- Input: {}", stats.total_input_tokens ).unwrap();
writeln!( output, "- Output: {}", stats.total_output_tokens ).unwrap();
writeln!( output, "- Cache Read: {}", stats.total_cache_read_tokens ).unwrap();
writeln!( output, "- Cache Creation: {}", stats.total_cache_creation_tokens ).unwrap();
}
if show_entries
{
let entries = session.entries()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load entries: {e}" ) ) )?;
output.push_str( "\nEntries:\n" );
for ( idx, entry ) in entries.iter().enumerate()
{
writeln!
(
output,
"{}. [{:?}] {} ({})",
idx + 1,
entry.entry_type,
entry.uuid,
entry.timestamp
).unwrap();
}
}
}
else if show_content
{
let entries = session.entries()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load entries: {e}" ) ) )?;
output.push_str( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" );
output.push( '\n' );
for entry in entries
{
let formatted = format_entry_content( entry, None );
output.push_str( &formatted );
output.push_str( "\n\n" );
}
output.push_str( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" );
if verbosity >= 2
{
output.push( '\n' );
output.push_str( "Session Metadata:\n" );
writeln!( output, "- Path: {}", session.storage_path().display() ).unwrap();
writeln!( output, "- Total Entries: {}", stats.total_entries ).unwrap();
writeln!( output, "- User/Assistant: {}/{}", stats.user_entries, stats.assistant_entries ).unwrap();
if verbosity >= 3
{
output.push_str( "\nToken Usage:\n" );
writeln!( output, "- Input: {}", stats.total_input_tokens ).unwrap();
writeln!( output, "- Output: {}", stats.total_output_tokens ).unwrap();
writeln!( output, "- Cache Read: {}", stats.total_cache_read_tokens ).unwrap();
writeln!( output, "- Cache Creation: {}", stats.total_cache_creation_tokens ).unwrap();
}
}
}
Ok( OutputData::new( output, "text" ) )
}
fn format_project_output(
project : &claude_storage_core::Project,
verbosity : i64
) -> core::result::Result< OutputData, ErrorData >
{
let stats = project.project_stats()
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to get project stats: {e}" )
))?;
let mut sessions = project.sessions()
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to list sessions: {e}" )
))?;
let mut output = String::new();
writeln!( output, "Project: {:?}", project.id() ).unwrap();
writeln!( output, "Storage: {}", project.storage_dir().display() ).unwrap();
output.push( '\n' );
writeln!( output, "Sessions: {} (Main: {}, Agent: {})",
stats.session_count,
stats.main_session_count,
stats.agent_session_count
).unwrap();
writeln!( output, "Total Entries: {}", stats.total_entries ).unwrap();
if verbosity >= 2
{
output.push_str( "Tokens:\n" );
writeln!( output, " Input: {}", stats.total_input_tokens ).unwrap();
writeln!( output, " Output: {}", stats.total_output_tokens ).unwrap();
}
output.push( '\n' );
if sessions.is_empty()
{
output.push_str( "No sessions found in this project.\n" );
}
else
{
output.push_str( "Sessions:\n" );
for session in &mut sessions
{
let session_stats = session.stats()
.map_err( | e | ErrorData::new
(
ErrorCode::InternalError,
format!( "Failed to get session stats: {e}" )
))?;
if verbosity == 0
{
writeln!( output, " - {}", session.id() ).unwrap();
}
else if verbosity == 1
{
let last = session_stats.last_timestamp
.unwrap_or_else( || "unknown".to_string() );
let e_noun = if session_stats.total_entries == 1 { "entry" } else { "entries" };
writeln!( output, " - {} ({} {e_noun}, last: {})",
session.id(),
session_stats.total_entries,
last
).unwrap();
}
else
{
writeln!( output, " - {}", session.id() ).unwrap();
writeln!( output, " Entries: {} (User: {}, Assistant: {})",
session_stats.total_entries,
session_stats.user_entries,
session_stats.assistant_entries
).unwrap();
if let Some( first ) = &session_stats.first_timestamp
{
writeln!( output, " First: {first}" ).unwrap();
}
if let Some( last ) = &session_stats.last_timestamp
{
writeln!( output, " Last: {last}" ).unwrap();
}
}
}
}
Ok( OutputData::new( output, "text" ) )
}
#[allow(clippy::needless_pass_by_value)]
#[inline]
pub fn count_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let target = cmd.get_string( "target" );
let project_id = cmd.get_string( "project" );
let session_id = cmd.get_string( "session" );
let storage = create_storage()?;
if target.is_none() && project_id.is_none()
{
if let Ok( project ) = storage.load_project_for_cwd()
{
let sessions = project.all_sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
let mut total_entries = 0usize;
for session in &sessions
{
match session.count_entries()
{
Ok( n ) => total_entries += n,
Err( e ) =>
{
eprintln!( "Warning: Skipping corrupted session {}: {e}", session.storage_path().display() );
}
}
}
let output = format!( "{total_entries}" );
return Ok( OutputData::new( output, "text" ) );
}
}
let target : &str = target.unwrap_or( "projects" );
let count = match target
{
"projects" =>
{
storage.count_projects()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to count projects: {e}" ) ) )?
}
"sessions" =>
{
let proj_id = project_id
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, "project parameter required for counting sessions".to_string() ) )?;
let project_id_parsed = parse_project_parameter( proj_id )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )?;
let project = storage.load_project( &project_id_parsed )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load project: {e}" ) ) )?;
project.count_sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to count sessions: {e}" ) ) )?
}
"entries" =>
{
let proj_id = project_id
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, "project parameter required for counting entries".to_string() ) )?;
let sess_id = session_id
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, "session parameter required for counting entries".to_string() ) )?;
let project_id_parsed = parse_project_parameter( proj_id )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )?;
let project = storage.load_project( &project_id_parsed )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load project: {e}" ) ) )?;
let sessions = project.all_sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
let session = sessions.iter()
.find( | s | s.id() == sess_id || s.id().starts_with( sess_id ) )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, format!( "Session not found: {sess_id}" ) ) )?;
session.count_entries()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to count entries: {e}" ) ) )?
}
_ =>
{
return Err( ErrorData::new( ErrorCode::InternalError, format!( "Invalid target: {target}" ) ) );
}
};
let output = format!( "{count}" );
Ok( OutputData::new( output, "text" ) )
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::needless_pass_by_value)]
#[inline]
pub fn search_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let query = cmd.get_string( "query" )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, "query is required".to_string() ) )?;
let project_id = cmd.get_string( "project" );
let session_id = cmd.get_string( "session" );
let case_sensitive = cmd.get_boolean( "case_sensitive" ).unwrap_or( false );
let entry_type = cmd.get_string( "entry_type" );
let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
if !( 0..=5 ).contains( &verbosity )
{
return Err
(
ErrorData::new
(
ErrorCode::InternalError,
format!( "Invalid verbosity: {verbosity}. Valid range: 0-5" )
)
);
}
let storage = create_storage()?;
let mut filter = claude_storage_core::SearchFilter::new( query )
.case_sensitive( case_sensitive );
if let Some( et ) = entry_type
{
match et
{
"user" => filter = filter.match_entry_type( claude_storage_core::EntryType::User ),
"assistant" => filter = filter.match_entry_type( claude_storage_core::EntryType::Assistant ),
"all" => { }
_ => return Err( ErrorData::new( ErrorCode::InternalError, format!( "Invalid entry_type: {et}. Valid values: user, assistant, all" ) ) ),
}
}
let mut all_matches = Vec::new();
if let Some( sess_id ) = session_id
{
let project = if let Some( proj_id ) = project_id
{
let project_id_parsed = parse_project_parameter( proj_id )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )?;
storage.load_project( &project_id_parsed )
}
else
{
storage.load_project_for_cwd()
}
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load project: {e}" ) ) )?;
let mut sessions = project.all_sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
let session = sessions.iter_mut()
.find( | s | s.id() == sess_id || s.id().starts_with( sess_id ) )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, format!( "Session not found: {sess_id}" ) ) )?;
let matches = session.search( &filter )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Search failed: {e}" ) ) )?;
for m in matches
{
all_matches.push( ( project.id().clone(), sess_id.to_string(), m ) );
}
}
else if let Some( proj_id ) = project_id
{
let project_id_parsed = parse_project_parameter( proj_id )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )?;
let project = storage.load_project( &project_id_parsed )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load project: {e}" ) ) )?;
let mut sessions = project.sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
for session in &mut sessions
{
let matches = session.search( &filter )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Search failed in session {}: {}", session.id(), e ) ) )?;
for m in matches
{
all_matches.push( ( project.id().clone(), session.id().to_string(), m ) );
}
}
}
else
{
let project = storage.load_project_for_cwd()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load project: {e}" ) ) )?;
let mut sessions = project.sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
for session in &mut sessions
{
let matches = session.search( &filter )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Search failed in session {}: {}", session.id(), e ) ) )?;
for m in matches
{
all_matches.push( ( project.id().clone(), session.id().to_string(), m ) );
}
}
}
let mut output = String::new();
if verbosity >= 1
{
let noun = if all_matches.len() == 1 { "match" } else { "matches" };
writeln!( output, "Found {} {noun}:\n", all_matches.len() ).unwrap();
}
for ( proj_id, sess_id, m ) in &all_matches
{
match verbosity
{
0 =>
{
writeln!( output, "{}", m.excerpt() ).unwrap();
}
1 =>
{
writeln!
(
output,
"[{}] [{:?}] {}",
sess_id,
m.entry_type(),
m.excerpt()
).unwrap();
}
_ =>
{
write!
(
output,
"Project: {:?}\nSession: {}\nEntry: {} ({})\nLine: {}\nExcerpt: {}\nFull Line: {}\n\n",
proj_id,
sess_id,
m.entry_index(),
match m.entry_type()
{
claude_storage_core::EntryType::User => "user",
claude_storage_core::EntryType::Assistant => "assistant",
},
m.line_number(),
m.excerpt(),
m.full_line()
).unwrap();
}
}
}
if all_matches.is_empty()
{
output.push_str( "No matches found.\n" );
}
Ok( OutputData::new( output, "text" ) )
}
#[allow(clippy::needless_pass_by_value)]
#[inline]
pub fn export_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let session_id = cmd.get_string( "session_id" )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, "session_id is required".to_string() ) )?;
let output_path_str = cmd.get_string( "output" )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, "output is required".to_string() ) )?;
let format_str = cmd.get_string( "format" ).unwrap_or( "markdown" );
let project_id = cmd.get_string( "project" );
let format = claude_storage_core::ExportFormat::from_str( format_str )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Invalid format: {e}" ) ) )?;
let storage = create_storage()?;
let project = if let Some( proj_id ) = project_id
{
let project_id_parsed = parse_project_parameter( proj_id )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )?;
storage.load_project( &project_id_parsed )
}
else
{
storage.load_project_for_cwd()
}
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to load project: {e}" ) ) )?;
let mut sessions = project.all_sessions()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list sessions: {e}" ) ) )?;
let session = sessions.iter_mut()
.find( | s | s.id() == session_id || s.id().starts_with( session_id ) )
.ok_or_else( || ErrorData::new( ErrorCode::InternalError, format!( "Session not found: {session_id}" ) ) )?;
let output_path = std::path::Path::new( output_path_str );
claude_storage_core::export_session_to_file( session, format, output_path )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Export failed: {e}" ) ) )?;
let output = format!( "Exported session '{session_id}' to {} (format: {format:?})", output_path.display() );
Ok( OutputData::new( output, "text" ) )
}
fn is_relevant_encoded( dir_name : &str, encoded_base : &str ) -> bool
{
let check = | candidate : &str | -> bool
{
encoded_base == candidate
|| encoded_base.starts_with( &format!( "{candidate}-" ) )
};
if check( dir_name ) { return true; }
let mut s = dir_name;
while let Some( idx ) = s.rfind( "--" )
{
s = &s[ ..idx ];
if check( s ) { return true; }
}
false
}
fn tilde_compress( path : &std::path::Path ) -> String
{
if let Ok( home ) = std::env::var( "HOME" )
{
if let Ok( rel ) = path.strip_prefix( std::path::Path::new( &home ) )
{
return format!( "~/{}", rel.display() );
}
}
path.display().to_string()
}
fn decode_path_via_fs( encoded : &str ) -> Option< std::path::PathBuf >
{
let inner = &encoded[ 1.. ]; let pieces : Vec< &str > = inner.split( '-' ).collect();
if pieces.is_empty() { return None; }
walk_fs( std::path::Path::new( "/" ), &pieces, 0, "" )
}
fn walk_fs(
base : &std::path::Path,
pieces : &[ &str ],
idx : usize,
segment : &str,
) -> Option< std::path::PathBuf >
{
if idx == pieces.len()
{
let candidate = if segment.is_empty() { base.to_path_buf() } else { base.join( segment ) };
return if candidate.exists() { Some( candidate ) } else { None };
}
let piece = pieces[ idx ];
if !segment.is_empty()
{
let next_base = base.join( segment );
if next_base.is_dir()
{
if let Some( result ) = walk_fs( &next_base, pieces, idx + 1, piece )
{
return Some( result );
}
}
}
let joined = if segment.is_empty()
{
piece.to_string()
}
else
{
format!( "{segment}_{piece}" )
};
walk_fs( base, pieces, idx + 1, &joined )
}
fn decode_project_display( dir_name : &str ) -> String
{
use claude_storage_core::decode_path;
if !dir_name.starts_with( '-' ) { return dir_name.to_string(); }
let mut parts = Vec::new();
let mut rest = dir_name;
loop
{
if let Some( idx ) = rest.find( "--" )
{
parts.push( &rest[ ..idx ] );
rest = &rest[ idx + 2.. ];
}
else
{
parts.push( rest );
break;
}
}
let base_encoded = parts[ 0 ];
let base_path = if let Ok( h ) = decode_path( base_encoded )
{
if h.exists()
{
h
}
else
{
decode_path_via_fs( base_encoded ).unwrap_or( h )
}
}
else
{
return dir_name.to_string();
};
let mut current = base_path;
for &topic in &parts[ 1.. ]
{
let topic_dir = format!( "-{}", topic.replace( '-', "_" ) );
let candidate = current.join( &topic_dir );
if candidate.exists() { current = candidate; } else { break; }
}
tilde_compress( ¤t )
}
fn session_mtime( session : &claude_storage_core::Session ) -> Option< std::time::SystemTime >
{
std::fs::metadata( session.storage_path() )
.ok()
.and_then( | m | m.modified().ok() )
}
fn is_zero_byte_session( session : &claude_storage_core::Session ) -> bool
{
std::fs::metadata( session.storage_path() )
.map( | m | m.len() == 0 )
.unwrap_or( false )
}
fn short_id( id : &str ) -> &str
{
if id.len() == 36 && id.as_bytes().get( 8 ) == Some( &b'-' ) { &id[ ..8 ] }
else { id }
}
fn format_relative_time( mtime : std::time::SystemTime ) -> String
{
let elapsed = std::time::SystemTime::now()
.duration_since( mtime )
.unwrap_or_default();
let secs = elapsed.as_secs();
if secs < 60 { format!( "{secs}s ago" ) }
else if secs < 3_600 { format!( "{}m ago", secs / 60 ) }
else if secs < 86_400 { format!( "{}h ago", secs / 3_600 ) }
else if secs < 2_592_000 { format!( "{}d ago", secs / 86_400 ) }
else { format!( "{}mo ago", secs / 2_592_000 ) }
}
fn last_text_entry( session : &mut claude_storage_core::Session ) -> Option< String >
{
use claude_storage_core::{ MessageContent, ContentBlock };
let entries = session.entries().ok()?;
for entry in entries.iter().rev()
{
match &entry.message
{
MessageContent::User( msg ) =>
{
if !msg.content.is_empty() { return Some( msg.content.clone() ); }
},
MessageContent::Assistant( msg ) =>
{
for block in &msg.content
{
if let ContentBlock::Text { text } = block
{
if !text.is_empty() { return Some( text.clone() ); }
}
}
},
}
}
None
}
fn truncate_message( text : &str ) -> String
{
let chars : Vec< char > = text.chars().collect();
if chars.len() > 50
{
let first : String = chars[ ..30 ].iter().collect();
let last : String = chars[ chars.len() - 30.. ].iter().collect();
format!( "{first}...{last}" )
}
else
{
text.to_string()
}
}
struct AgentMeta { agent_type : String }
struct AgentInfo
{
session : claude_storage_core::Session,
agent_type : String,
}
struct SessionFamily
{
root : Option< claude_storage_core::Session >,
agents : Vec< AgentInfo >,
}
struct ProjectSummary
{
display_path : String,
session_count : usize,
last_mtime : std::time::SystemTime,
last_session_id : String,
last_session_entries : usize,
last_message : Option< String >,
}
fn parse_agent_meta( agent_path : &std::path::Path ) -> AgentMeta
{
let meta_path = agent_path.with_extension( "meta.json" );
let content = match std::fs::read_to_string( &meta_path )
{
Ok( c ) if !c.is_empty() => c,
_ => return AgentMeta { agent_type : "unknown".into() },
};
let Ok( val ) = claude_storage_core::parse_json( &content ) else
{
return AgentMeta { agent_type : "unknown".into() };
};
let agent_type = val.as_object()
.and_then( | obj | obj.get( "agentType" ) )
.and_then( claude_storage_core::JsonValue::as_str )
.filter( | s | !s.trim().is_empty() )
.unwrap_or( "unknown" )
.to_string();
AgentMeta { agent_type }
}
fn extract_parent_hierarchical( agent_path : &std::path::Path ) -> Option< String >
{
agent_path
.parent()? .parent()? .file_name()?
.to_str()
.map( String::from )
}
fn extract_parent_flat( agent_path : &std::path::Path ) -> Option< String >
{
use std::io::BufRead;
let file = std::fs::File::open( agent_path ).ok()?;
let mut reader = std::io::BufReader::new( file );
let mut line = String::new();
reader.read_line( &mut line ).ok()?;
let val = claude_storage_core::parse_json( &line ).ok()?;
val.as_object()?
.get( "sessionId" )?
.as_str()
.map( String::from )
}
fn is_hierarchical_format( agents : &[ &claude_storage_core::Session ] ) -> bool
{
agents.iter().any( | s |
s.storage_path().components().any( | c | c.as_os_str() == "subagents" )
)
}
fn resolve_agent_parents(
agents : Vec< claude_storage_core::Session >,
) -> ( std::collections::HashMap< String, Vec< AgentInfo > >, Vec< AgentInfo > )
{
use std::collections::HashMap;
let agent_refs : Vec< &claude_storage_core::Session > = agents.iter().collect();
let hierarchical = is_hierarchical_format( &agent_refs );
let mut parent_map : HashMap< String, Vec< AgentInfo > > = HashMap::new();
let mut orphans : Vec< AgentInfo > = Vec::new();
for agent in agents
{
let meta = parse_agent_meta( agent.storage_path() );
let parent_id = if hierarchical
{
extract_parent_hierarchical( agent.storage_path() )
}
else
{
extract_parent_flat( agent.storage_path() )
};
let info = AgentInfo { session : agent, agent_type : meta.agent_type };
match parent_id
{
Some( pid ) => parent_map.entry( pid ).or_default().push( info ),
None => orphans.push( info ),
}
}
( parent_map, orphans )
}
fn build_families(
sessions : Vec< claude_storage_core::Session >,
) -> Vec< SessionFamily >
{
let mut roots : Vec< claude_storage_core::Session > = Vec::new();
let mut agents : Vec< claude_storage_core::Session > = Vec::new();
for s in sessions
{
if s.is_agent_session() { agents.push( s ); }
else { roots.push( s ); }
}
if agents.is_empty()
{
return roots.into_iter()
.map( | r | SessionFamily { root : Some( r ), agents : Vec::new() } )
.collect();
}
let ( mut parent_map, mut orphan_agents ) = resolve_agent_parents( agents );
let mut families : Vec< SessionFamily > = Vec::new();
for root in roots
{
let children = parent_map.remove( root.id() ).unwrap_or_default();
families.push( SessionFamily { root : Some( root ), agents : children } );
}
for ( _pid, agents_vec ) in parent_map
{
orphan_agents.extend( agents_vec );
}
if !orphan_agents.is_empty()
{
families.push( SessionFamily { root : None, agents : orphan_agents } );
}
families.sort_by( | a, b |
{
let ta = a.root.as_ref().and_then( session_mtime )
.unwrap_or( std::time::UNIX_EPOCH );
let tb = b.root.as_ref().and_then( session_mtime )
.unwrap_or( std::time::UNIX_EPOCH );
tb.cmp( &ta )
} );
families
}
fn format_type_breakdown( agents : &[ AgentInfo ] ) -> String
{
use std::collections::HashMap;
let mut counts : HashMap< &str, usize > = HashMap::new();
for a in agents
{
*counts.entry( a.agent_type.as_str() ).or_default() += 1;
}
let mut pairs : Vec< ( &str, usize ) > = counts.into_iter().collect();
pairs.sort_by( | a, b | b.1.cmp( &a.1 ).then_with( || a.0.cmp( b.0 ) ) );
pairs.iter()
.map( | ( t, n ) | format!( "{n}\u{00d7}{t}" ) )
.collect::< Vec< _ > >()
.join( ", " )
}
fn aggregate_projects(
groups : &mut std::collections::BTreeMap< String, Vec< claude_storage_core::Session > >,
) -> Vec< ProjectSummary >
{
let mut summaries : Vec< ProjectSummary > = Vec::new();
for ( display_path, sessions ) in groups.iter_mut()
{
let best = sessions
.iter()
.enumerate()
.filter( | ( _, s ) | !is_zero_byte_session( s ) )
.filter_map( | ( i, s ) | session_mtime( s ).map( | t | ( i, t ) ) )
.max_by_key( | &( _, t ) | t );
let Some( ( best_idx, best_time ) ) = best else { continue };
let session_count = sessions.iter().filter( | s | !is_zero_byte_session( s ) ).count();
let last_session_id = short_id( sessions[ best_idx ].id() ).to_string();
let last_session_entries = sessions[ best_idx ].count_entries().unwrap_or( 0 );
let last_message = last_text_entry( &mut sessions[ best_idx ] );
summaries.push( ProjectSummary
{
display_path : display_path.clone(),
session_count,
last_mtime : best_time,
last_session_id,
last_session_entries,
last_message,
} );
}
summaries.sort_by( | a, b | b.last_mtime.cmp( &a.last_mtime ) );
summaries
}
fn render_active_project_summary(
groups : &mut std::collections::BTreeMap< String, Vec< claude_storage_core::Session > >,
) -> Option< String >
{
let summaries = aggregate_projects( groups );
let summary = summaries.into_iter().next()?;
let age = format_relative_time( summary.last_mtime );
let s_noun = if summary.session_count == 1 { "session" } else { "sessions" };
let e_count = summary.last_session_entries;
let e_noun = if e_count == 1 { "entry" } else { "entries" };
let last_txt = summary.last_message
.as_deref()
.map_or_else( || "(no text content)".to_string(), truncate_message );
let mut out = String::new();
writeln!(
out,
"Active project {} ({} {}, last active {})",
summary.display_path, summary.session_count, s_noun, age
).unwrap();
writeln!(
out,
"Last session: {} {} ({} {})",
summary.last_session_id, age, e_count, e_noun
).unwrap();
writeln!( out ).unwrap();
writeln!( out, "Last message:" ).unwrap();
writeln!( out, " {last_txt}" ).unwrap();
Some( out )
}
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_lines)]
#[inline]
pub fn projects_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
use std::collections::BTreeMap;
use std::path::PathBuf;
use claude_storage_core::{ Session, SessionFilter, encode_path };
let is_default = cmd.get_string( "scope" ).is_none()
&& cmd.get_string( "path" ).is_none()
&& cmd.get_string( "session" ).is_none()
&& cmd.get_boolean( "agent" ).is_none()
&& cmd.get_integer( "min_entries" ).is_none()
&& cmd.get_integer( "limit" ).is_none();
let scope_raw = cmd.get_string( "scope" ).unwrap_or( "under" );
let scope = scope_raw.to_lowercase();
if !matches!( scope.as_str(), "local" | "relevant" | "under" | "global" )
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "scope must be relevant|local|under|global, got {scope_raw}" ),
) );
}
let verbosity = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
if !( 0..=5 ).contains( &verbosity )
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "Invalid verbosity: {verbosity}. Valid range: 0-5" ),
) );
}
let min_entries_filter = if let Some( n ) = cmd.get_integer( "min_entries" )
{
if n < 0
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "Invalid min_entries: {n}. Must be non-negative" ),
) );
}
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
Some( n as usize )
}
else { None };
let limit_cap = if let Some( n ) = cmd.get_integer( "limit" )
{
if n < 0
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "Invalid limit: {n}. Must be non-negative" ),
) );
}
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let v = n as usize;
if v == 0 { usize::MAX } else { v }
}
else { usize::MAX };
let agent_filter = cmd.get_boolean( "agent" );
let session_id_filter = cmd.get_string( "session" );
let base_path : PathBuf = if let Some( p ) = cmd.get_string( "path" )
{
resolve_path_parameter( p )
.map( PathBuf::from )
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to resolve path '{p}': {e}" ),
) )?
}
else
{
std::env::current_dir()
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to get current directory: {e}" ),
) )?
};
let storage = create_storage()?;
let all_projects = storage.list_projects()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list projects: {e}" ) ) )?;
let encoded_base : Option< String > = if scope == "global"
{
None
}
else
{
Some(
encode_path( &base_path )
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to encode base path '{}': {e}", base_path.display() ),
) )?
)
};
let project_matches = | project : &claude_storage_core::Project | -> bool
{
if scope == "global" { return true; }
let Some( ref eb ) = encoded_base else { return false };
let dir_name = project
.storage_dir()
.file_name()
.and_then( | n | n.to_str() )
.unwrap_or( "" );
match scope.as_str()
{
"local" => dir_name == eb || dir_name.starts_with( &format!( "{eb}--" ) ),
"under" =>
{
if dir_name != eb && !dir_name.starts_with( &format!( "{eb}-" ) ) { return false; }
if dir_name == eb { return true; }
let candidate_base = dir_name.find( "--" ).map_or( dir_name, | i | &dir_name[ ..i ] );
decode_path_via_fs( candidate_base )
.is_none_or( | p | p.starts_with( &base_path ) )
},
"relevant" =>
{
if !is_relevant_encoded( dir_name, eb ) { return false; }
let candidate_base = dir_name.find( "--" ).map_or( dir_name, | i | &dir_name[ ..i ] );
if candidate_base == eb { return true; }
decode_path_via_fs( candidate_base )
.is_none_or( | p | base_path.starts_with( &p ) )
},
_ => false,
}
};
let session_filter = SessionFilter
{
agent_only : agent_filter,
min_entries : min_entries_filter,
session_id_substring : session_id_filter.map( std::string::ToString::to_string ),
};
let mut groups : BTreeMap< String, Vec< Session > > = BTreeMap::new();
for mut project in all_projects
{
if !project_matches( &project ) { continue; }
let dir_name = project
.storage_dir()
.file_name()
.and_then( | n | n.to_str() )
.unwrap_or( "" )
.to_string();
let display_path = decode_project_display( &dir_name );
let Ok( sessions ) = project.sessions_filtered( &session_filter ) else { continue };
if sessions.is_empty() { continue; }
groups
.entry( display_path )
.or_default()
.extend( sessions );
}
for sessions in groups.values_mut()
{
sessions.sort_by( | a, b |
{
let ta = session_mtime( a ).unwrap_or( std::time::UNIX_EPOCH );
let tb = session_mtime( b ).unwrap_or( std::time::UNIX_EPOCH );
tb.cmp( &ta )
} );
}
if is_default
{
let summary = render_active_project_summary( &mut groups )
.unwrap_or_else( || "No active project found.\n".to_string() );
return Ok( OutputData::new( summary, "text" ) );
}
let summaries = aggregate_projects( &mut groups );
if verbosity == 0
{
let mut output = String::new();
for summary in &summaries
{
writeln!( output, "{}", summary.display_path ).unwrap();
}
return Ok( OutputData::new( output, "text" ) );
}
let total_projects = summaries.len();
let mut output = String::new();
let use_families = agent_filter.is_none();
let p_noun = if total_projects == 1 { "project" } else { "projects" };
writeln!( output, "Found {total_projects} {p_noun}:\n" ).unwrap();
for summary in summaries
{
let sessions = groups.remove( &summary.display_path ).unwrap_or_default();
let display_path = &summary.display_path;
if use_families
{
let families = build_families( sessions );
let root_count = families
.iter()
.filter( | f | f.root.as_ref().is_some_and( | s | !is_zero_byte_session( s ) ) )
.count();
let agent_count : usize = families.iter().map( | f | f.agents.len() ).sum();
if agent_count > 0
{
let r_noun = if root_count == 1 { "conversation" } else { "conversations" };
let a_noun = if agent_count == 1 { "agent" } else { "agents" };
writeln!( output, "{display_path}: ({root_count} {r_noun}, {agent_count} {a_noun})" ).unwrap();
}
else
{
let noun = if root_count == 1 { "session" } else { "sessions" };
writeln!( output, "{display_path}: ({root_count} {noun})" ).unwrap();
}
if verbosity == 1
{
render_families_v1( &mut output, &families, limit_cap );
}
else
{
render_families_v2( &mut output, &families );
}
}
else
{
let displayable : Vec< &Session > = sessions
.iter()
.filter( | &s | s.is_agent_session() || !is_zero_byte_session( s ) )
.collect();
let group_count = displayable.len();
let group_noun = if group_count == 1 { "session" } else { "sessions" };
writeln!( output, "{display_path}: ({group_count} {group_noun})" ).unwrap();
let show_count = displayable.len().min( limit_cap );
for ( i, &session ) in displayable[ ..show_count ].iter().enumerate()
{
let marker = if i == 0 { '*' } else { '-' };
let id_str = short_id( session.id() );
let time_str = session_mtime( session )
.map( | t | format!( " {}", format_relative_time( t ) ) )
.unwrap_or_default();
let count_str = session
.count_entries()
.map( | n |
{
let noun = if n == 1 { "entry" } else { "entries" };
format!( " ({n} {noun})" )
} )
.unwrap_or_default();
writeln!( output, " {marker} {id_str}{time_str}{count_str}" ).unwrap();
}
if displayable.len() > limit_cap
{
let hidden = displayable.len() - limit_cap;
let hidden_noun = if hidden == 1 { "session" } else { "sessions" };
writeln!(
output,
" ... and {hidden} more {hidden_noun} (use limit::0 to list all)"
).unwrap();
}
}
writeln!( output ).unwrap();
}
Ok( OutputData::new( output, "text" ) )
}
fn format_agent_bracket( agents : &[ AgentInfo ] ) -> String
{
if agents.is_empty() { return String::new(); }
let n = agents.len();
let noun = if n == 1 { "agent" } else { "agents" };
let breakdown = format_type_breakdown( agents );
format!( " [{n} {noun}: {breakdown}]" )
}
fn format_session_line(
session : &claude_storage_core::Session,
marker : char,
) -> String
{
let id_str = short_id( session.id() );
let time_str = session_mtime( session )
.map( | t | format!( " {}", format_relative_time( t ) ) )
.unwrap_or_default();
let count_str = session
.count_entries()
.map( | n |
{
let noun = if n == 1 { "entry" } else { "entries" };
format!( " ({n} {noun})" )
} )
.unwrap_or_default();
format!( " {marker} {id_str}{time_str}{count_str}" )
}
fn render_families_v1(
output : &mut String,
families : &[ SessionFamily ],
limit_cap : usize,
)
{
let displayable : Vec< &SessionFamily > = families.iter()
.filter( | f | !f.root.as_ref().is_some_and( is_zero_byte_session ) )
.collect();
let show_count = displayable.len().min( limit_cap );
for ( i, family ) in displayable[ ..show_count ].iter().enumerate()
{
if let Some( root ) = &family.root
{
let marker = if i == 0 { '*' } else { '-' };
let line = format_session_line( root, marker );
let bracket = format_agent_bracket( &family.agents );
writeln!( output, "{line}{bracket}" ).unwrap();
}
else
{
let bracket = format_agent_bracket( &family.agents );
writeln!( output, " ? (orphan){bracket}" ).unwrap();
}
}
if displayable.len() > limit_cap
{
let hidden = displayable.len() - limit_cap;
let noun = if hidden == 1 { "session" } else { "sessions" };
writeln!( output, " ... and {hidden} more {noun} (use limit::0 to list all)" ).unwrap();
}
}
fn render_families_v2(
output : &mut String,
families : &[ SessionFamily ],
)
{
for family in families
{
if let Some( root ) = &family.root
{
let id = root.id();
let count_str = root
.count_entries()
.map( | n | {
let noun = if n == 1 { "entry" } else { "entries" };
format!( " ({n} {noun})" )
} )
.unwrap_or_default();
writeln!( output, " - {id}{count_str}" ).unwrap();
}
else
{
writeln!( output, " ? (orphan agents)" ).unwrap();
}
for ( j, agent ) in family.agents.iter().enumerate()
{
let connector = if j + 1 < family.agents.len() { "\u{251c}\u{2500}" } else { "\u{2514}\u{2500}" };
let aid = agent.session.id();
let atype = &agent.agent_type;
let acount = agent.session
.count_entries()
.map( | n | {
let noun = if n == 1 { "entry" } else { "entries" };
format!( " {n} {noun}" )
} )
.unwrap_or_default();
writeln!( output, " {connector} {aid} {atype}{acount}" ).unwrap();
}
}
}
fn resolve_cmd_path( cmd : &VerifiedCommand ) -> core::result::Result< std::path::PathBuf, ErrorData >
{
let resolved = cmd.get_string( "path" )
.map( | p | resolve_path_parameter( p )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )
)
.transpose()?;
match resolved
{
Some( s ) => Ok( std::path::PathBuf::from( s ) ),
None =>
{
std::env::current_dir()
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to get current directory: {e}" ),
) )
}
}
}
fn validate_topic( topic : &str ) -> core::result::Result< (), ErrorData >
{
if topic.is_empty()
{
return Err( ErrorData::new(
ErrorCode::InternalError,
"topic must be non-empty".to_string(),
) );
}
if topic.contains( '/' )
{
return Err( ErrorData::new(
ErrorCode::InternalError,
"topic must not contain path separators".to_string(),
) );
}
Ok( () )
}
#[ allow( clippy::needless_pass_by_value ) ]
#[ inline ]
pub fn path_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let base = resolve_cmd_path( &cmd )?;
let session_dir = if let Some( topic ) = cmd.get_string( "topic" )
{
validate_topic( topic )?;
base.join( format!( "-{topic}" ) )
}
else
{
base
};
let storage_path = claude_storage_core::continuation::to_storage_path_for( &session_dir )
.ok_or_else( || ErrorData::new(
ErrorCode::InternalError,
"Failed to compute storage path (HOME not set or invalid path)".to_string(),
) )?;
Ok( OutputData::new( format!( "{}/", storage_path.display() ), "text" ) )
}
#[ allow( clippy::needless_pass_by_value ) ]
#[ inline ]
pub fn exists_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let base = resolve_cmd_path( &cmd )?;
let session_dir = if let Some( topic ) = cmd.get_string( "topic" )
{
validate_topic( topic )?;
base.join( format!( "-{topic}" ) )
}
else
{
base
};
if claude_storage_core::continuation::check_continuation( &session_dir )
{
Ok( OutputData::new( "sessions exist".to_string(), "text" ) )
}
else
{
Err( ErrorData::new( ErrorCode::InternalError, "no sessions".to_string() ) )
}
}
#[ allow( clippy::needless_pass_by_value ) ]
#[ inline ]
pub fn session_dir_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let path_str = cmd.get_string( "path" )
.ok_or_else( || ErrorData::new(
ErrorCode::InternalError,
"path parameter is required for .session.dir".to_string(),
) )?;
let resolved = resolve_path_parameter( path_str )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )?;
let base = std::path::PathBuf::from( resolved );
let topic = cmd.get_string( "topic" ).unwrap_or( "default_topic" );
validate_topic( topic )?;
let session_dir = base.join( format!( "-{topic}" ) );
Ok( OutputData::new( format!( "{}", session_dir.display() ), "text" ) )
}
#[ allow( clippy::needless_pass_by_value ) ]
#[ inline ]
pub fn session_ensure_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
let path_str = cmd.get_string( "path" )
.ok_or_else( || ErrorData::new(
ErrorCode::InternalError,
"path parameter is required for .session.ensure".to_string(),
) )?;
let resolved = resolve_path_parameter( path_str )
.map_err( | e | ErrorData::new( ErrorCode::InternalError, e ) )?;
let base = std::path::PathBuf::from( resolved );
let topic = cmd.get_string( "topic" ).unwrap_or( "default_topic" );
validate_topic( topic )?;
let forced_strategy = if let Some( s ) = cmd.get_string( "strategy" )
{
match s.to_lowercase().as_str()
{
"resume" => Some( true ),
"fresh" => Some( false ),
other => return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "strategy must be resume|fresh, got {other}" ),
) ),
}
}
else
{
None
};
let session_dir = base.join( format!( "-{topic}" ) );
std::fs::create_dir_all( &session_dir ).map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to create session directory: {e}" ),
) )?;
let is_resume = forced_strategy.unwrap_or_else(
|| claude_storage_core::continuation::check_continuation( &session_dir )
);
let strategy = if is_resume { "resume" } else { "fresh" };
Ok( OutputData::new( format!( "{}\n{strategy}", session_dir.display() ), "text" ) )
}