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
699 changes: 696 additions & 3 deletions thop-rust/src/cli/interactive.rs

Large diffs are not rendered by default.

314 changes: 293 additions & 21 deletions thop-rust/src/cli/mod.rs

Large diffs are not rendered by default.

107 changes: 106 additions & 1 deletion thop-rust/src/cli/proxy.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::io::{self, BufRead, Write};

use crate::error::Result;
use crate::error::{Result, SessionError, ThopError};
use super::App;

/// Run proxy mode for AI agent integration
Expand All @@ -21,6 +21,14 @@ pub fn run_proxy(app: &mut App) -> Result<()> {
continue;
}

// Check for slash commands
if input.starts_with('/') {
if let Err(e) = handle_proxy_slash_command(app, input) {
app.output_error(&e);
}
continue;
}

// Execute command on active session
match app.sessions.execute(input) {
Ok(result) => {
Expand Down Expand Up @@ -58,6 +66,103 @@ pub fn run_proxy(app: &mut App) -> Result<()> {
Ok(())
}

/// Handle slash commands in proxy mode
fn handle_proxy_slash_command(app: &mut App, input: &str) -> Result<()> {
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.is_empty() {
return Ok(());
}

let cmd = parts[0].to_lowercase();
let args = &parts[1..];

match cmd.as_str() {
"/status" | "/s" => {
app.print_status()
}

"/connect" | "/c" => {
if args.is_empty() {
return Err(ThopError::Other("usage: /connect <session>".to_string()));
}
let name = args[0];
if !app.sessions.has_session(name) {
return Err(SessionError::session_not_found(name).into());
}
println!("Connecting to {}...", name);
app.sessions.connect(name)?;
println!("Connected to {}", name);
Ok(())
}

"/switch" | "/sw" => {
if args.is_empty() {
return Err(ThopError::Other("usage: /switch <session>".to_string()));
}
let name = args[0];
if !app.sessions.has_session(name) {
return Err(SessionError::session_not_found(name).into());
}

// For SSH sessions, connect if not connected
let session = app.sessions.get_session(name).unwrap();
if session.session_type() == "ssh" && !session.is_connected() {
println!("Connecting to {}...", name);
app.sessions.connect(name)?;
println!("Connected to {}", name);
}

app.sessions.set_active_session(name)?;
println!("Switched to {}", name);
Ok(())
}

"/local" | "/l" => {
app.sessions.set_active_session("local")?;
println!("Switched to local");
Ok(())
}

"/close" | "/disconnect" | "/d" => {
if args.is_empty() {
return Err(ThopError::Other("usage: /close <session>".to_string()));
}
let name = args[0];
if !app.sessions.has_session(name) {
return Err(SessionError::session_not_found(name).into());
}

let session = app.sessions.get_session(name).unwrap();
if session.session_type() == "local" {
println!("Cannot close local session");
return Ok(());
}

if !session.is_connected() {
println!("Session '{}' is not connected", name);
return Ok(());
}

app.sessions.disconnect(name)?;
println!("Disconnected from {}", name);

// Switch to local if we closed the active session
if app.sessions.get_active_session_name() == name {
app.sessions.set_active_session("local")?;
println!("Switched to local");
}
Ok(())
}

_ => {
Err(ThopError::Other(format!(
"unknown command: {} (supported: /connect, /switch, /local, /status, /close)",
cmd
)))
}
}
}

#[cfg(test)]
mod tests {
// Proxy mode tests would typically be integration tests
Expand Down
186 changes: 186 additions & 0 deletions thop-rust/src/logger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! Simple logging module for thop

use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;

/// Log levels
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
Off,
Error,
Warn,
Info,
Debug,
}

impl LogLevel {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"off" | "none" => LogLevel::Off,
"error" => LogLevel::Error,
"warn" | "warning" => LogLevel::Warn,
"info" => LogLevel::Info,
"debug" => LogLevel::Debug,
_ => LogLevel::Info,
}
}
}

/// Global logger state
static LOGGER: Mutex<Option<Logger>> = Mutex::new(None);

/// Logger configuration and state
pub struct Logger {
level: LogLevel,
log_file: Option<PathBuf>,
}

impl Logger {
/// Initialize the global logger
pub fn init(level: LogLevel, log_file: Option<PathBuf>) {
let mut logger = LOGGER.lock().unwrap();
*logger = Some(Logger { level, log_file });
}

/// Get the default log file path
pub fn default_log_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")))
.join("thop")
.join("thop.log")
}

/// Log a message at the specified level
fn log(&self, level: LogLevel, message: &str) {
if level > self.level {
return;
}

let level_str = match level {
LogLevel::Off => return,
LogLevel::Error => "ERROR",
LogLevel::Warn => "WARN",
LogLevel::Info => "INFO",
LogLevel::Debug => "DEBUG",
};

let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let formatted = format!("[{}] {} - {}\n", timestamp, level_str, message);

// Write to log file if configured
if let Some(ref path) = self.log_file {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}

if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
file.write_all(formatted.as_bytes()).ok();
}
}

// Also write to stderr for error level in debug mode
if level == LogLevel::Error || (level == LogLevel::Debug && self.level >= LogLevel::Debug) {
eprint!("{}", formatted);
}
}
}

/// Log an error message
pub fn error(message: &str) {
if let Ok(guard) = LOGGER.lock() {
if let Some(ref logger) = *guard {
logger.log(LogLevel::Error, message);
}
}
}

/// Log a warning message
pub fn warn(message: &str) {
if let Ok(guard) = LOGGER.lock() {
if let Some(ref logger) = *guard {
logger.log(LogLevel::Warn, message);
}
}
}

/// Log an info message
pub fn info(message: &str) {
if let Ok(guard) = LOGGER.lock() {
if let Some(ref logger) = *guard {
logger.log(LogLevel::Info, message);
}
}
}

/// Log a debug message
pub fn debug(message: &str) {
if let Ok(guard) = LOGGER.lock() {
if let Some(ref logger) = *guard {
logger.log(LogLevel::Debug, message);
}
}
}

/// Log a formatted error message
#[macro_export]
macro_rules! log_error {
($($arg:tt)*) => {
$crate::logger::error(&format!($($arg)*))
};
}

/// Log a formatted warning message
#[macro_export]
macro_rules! log_warn {
($($arg:tt)*) => {
$crate::logger::warn(&format!($($arg)*))
};
}

/// Log a formatted info message
#[macro_export]
macro_rules! log_info {
($($arg:tt)*) => {
$crate::logger::info(&format!($($arg)*))
};
}

/// Log a formatted debug message
#[macro_export]
macro_rules! log_debug {
($($arg:tt)*) => {
$crate::logger::debug(&format!($($arg)*))
};
}

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

#[test]
fn test_log_level_from_str() {
assert_eq!(LogLevel::from_str("debug"), LogLevel::Debug);
assert_eq!(LogLevel::from_str("DEBUG"), LogLevel::Debug);
assert_eq!(LogLevel::from_str("info"), LogLevel::Info);
assert_eq!(LogLevel::from_str("warn"), LogLevel::Warn);
assert_eq!(LogLevel::from_str("warning"), LogLevel::Warn);
assert_eq!(LogLevel::from_str("error"), LogLevel::Error);
assert_eq!(LogLevel::from_str("off"), LogLevel::Off);
assert_eq!(LogLevel::from_str("none"), LogLevel::Off);
assert_eq!(LogLevel::from_str("unknown"), LogLevel::Info);
}

#[test]
fn test_log_level_ordering() {
assert!(LogLevel::Debug > LogLevel::Info);
assert!(LogLevel::Info > LogLevel::Warn);
assert!(LogLevel::Warn > LogLevel::Error);
assert!(LogLevel::Error > LogLevel::Off);
}
}
3 changes: 3 additions & 0 deletions thop-rust/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
mod cli;
mod config;
mod error;
mod logger;
mod mcp;
mod session;
mod sshconfig;
mod state;

use std::process::ExitCode;
Expand Down
Loading
Loading