Skip to content

Commit c2c59b0

Browse files
committed
Bring Rust implementation to full feature parity with Go
- Add CLI flags: -c (command), --completions, --mcp, --json, -v, -q - Add slash commands: /env, /auth, /read, /write, /trust, /add-session, /bg, /jobs, /fg, /kill, /copy, /shell (33 total, matching Go) - Implement MCP server with JSON-RPC 2.0 protocol support - Tools: connect, switch, close, status, execute - Structured error codes with suggestions - Add background job execution with thread-safe state management - Add file transfer between local/SSH sessions (/copy) - Add interactive shell command support (/shell) - Add SSH config parsing and logging system
1 parent 67f5d7d commit c2c59b0

16 files changed

Lines changed: 3359 additions & 48 deletions

File tree

thop-rust/src/cli/interactive.rs

Lines changed: 696 additions & 3 deletions
Large diffs are not rendered by default.

thop-rust/src/cli/mod.rs

Lines changed: 293 additions & 21 deletions
Large diffs are not rendered by default.

thop-rust/src/cli/proxy.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::io::{self, BufRead, Write};
22

3-
use crate::error::Result;
3+
use crate::error::{Result, SessionError, ThopError};
44
use super::App;
55

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

24+
// Check for slash commands
25+
if input.starts_with('/') {
26+
if let Err(e) = handle_proxy_slash_command(app, input) {
27+
app.output_error(&e);
28+
}
29+
continue;
30+
}
31+
2432
// Execute command on active session
2533
match app.sessions.execute(input) {
2634
Ok(result) => {
@@ -58,6 +66,103 @@ pub fn run_proxy(app: &mut App) -> Result<()> {
5866
Ok(())
5967
}
6068

69+
/// Handle slash commands in proxy mode
70+
fn handle_proxy_slash_command(app: &mut App, input: &str) -> Result<()> {
71+
let parts: Vec<&str> = input.split_whitespace().collect();
72+
if parts.is_empty() {
73+
return Ok(());
74+
}
75+
76+
let cmd = parts[0].to_lowercase();
77+
let args = &parts[1..];
78+
79+
match cmd.as_str() {
80+
"/status" | "/s" => {
81+
app.print_status()
82+
}
83+
84+
"/connect" | "/c" => {
85+
if args.is_empty() {
86+
return Err(ThopError::Other("usage: /connect <session>".to_string()));
87+
}
88+
let name = args[0];
89+
if !app.sessions.has_session(name) {
90+
return Err(SessionError::session_not_found(name).into());
91+
}
92+
println!("Connecting to {}...", name);
93+
app.sessions.connect(name)?;
94+
println!("Connected to {}", name);
95+
Ok(())
96+
}
97+
98+
"/switch" | "/sw" => {
99+
if args.is_empty() {
100+
return Err(ThopError::Other("usage: /switch <session>".to_string()));
101+
}
102+
let name = args[0];
103+
if !app.sessions.has_session(name) {
104+
return Err(SessionError::session_not_found(name).into());
105+
}
106+
107+
// For SSH sessions, connect if not connected
108+
let session = app.sessions.get_session(name).unwrap();
109+
if session.session_type() == "ssh" && !session.is_connected() {
110+
println!("Connecting to {}...", name);
111+
app.sessions.connect(name)?;
112+
println!("Connected to {}", name);
113+
}
114+
115+
app.sessions.set_active_session(name)?;
116+
println!("Switched to {}", name);
117+
Ok(())
118+
}
119+
120+
"/local" | "/l" => {
121+
app.sessions.set_active_session("local")?;
122+
println!("Switched to local");
123+
Ok(())
124+
}
125+
126+
"/close" | "/disconnect" | "/d" => {
127+
if args.is_empty() {
128+
return Err(ThopError::Other("usage: /close <session>".to_string()));
129+
}
130+
let name = args[0];
131+
if !app.sessions.has_session(name) {
132+
return Err(SessionError::session_not_found(name).into());
133+
}
134+
135+
let session = app.sessions.get_session(name).unwrap();
136+
if session.session_type() == "local" {
137+
println!("Cannot close local session");
138+
return Ok(());
139+
}
140+
141+
if !session.is_connected() {
142+
println!("Session '{}' is not connected", name);
143+
return Ok(());
144+
}
145+
146+
app.sessions.disconnect(name)?;
147+
println!("Disconnected from {}", name);
148+
149+
// Switch to local if we closed the active session
150+
if app.sessions.get_active_session_name() == name {
151+
app.sessions.set_active_session("local")?;
152+
println!("Switched to local");
153+
}
154+
Ok(())
155+
}
156+
157+
_ => {
158+
Err(ThopError::Other(format!(
159+
"unknown command: {} (supported: /connect, /switch, /local, /status, /close)",
160+
cmd
161+
)))
162+
}
163+
}
164+
}
165+
61166
#[cfg(test)]
62167
mod tests {
63168
// Proxy mode tests would typically be integration tests

thop-rust/src/logger.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
//! Simple logging module for thop
2+
3+
use std::fs::{self, OpenOptions};
4+
use std::io::Write;
5+
use std::path::PathBuf;
6+
use std::sync::Mutex;
7+
8+
/// Log levels
9+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10+
pub enum LogLevel {
11+
Off,
12+
Error,
13+
Warn,
14+
Info,
15+
Debug,
16+
}
17+
18+
impl LogLevel {
19+
pub fn from_str(s: &str) -> Self {
20+
match s.to_lowercase().as_str() {
21+
"off" | "none" => LogLevel::Off,
22+
"error" => LogLevel::Error,
23+
"warn" | "warning" => LogLevel::Warn,
24+
"info" => LogLevel::Info,
25+
"debug" => LogLevel::Debug,
26+
_ => LogLevel::Info,
27+
}
28+
}
29+
}
30+
31+
/// Global logger state
32+
static LOGGER: Mutex<Option<Logger>> = Mutex::new(None);
33+
34+
/// Logger configuration and state
35+
pub struct Logger {
36+
level: LogLevel,
37+
log_file: Option<PathBuf>,
38+
}
39+
40+
impl Logger {
41+
/// Initialize the global logger
42+
pub fn init(level: LogLevel, log_file: Option<PathBuf>) {
43+
let mut logger = LOGGER.lock().unwrap();
44+
*logger = Some(Logger { level, log_file });
45+
}
46+
47+
/// Get the default log file path
48+
pub fn default_log_path() -> PathBuf {
49+
dirs::data_dir()
50+
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")))
51+
.join("thop")
52+
.join("thop.log")
53+
}
54+
55+
/// Log a message at the specified level
56+
fn log(&self, level: LogLevel, message: &str) {
57+
if level > self.level {
58+
return;
59+
}
60+
61+
let level_str = match level {
62+
LogLevel::Off => return,
63+
LogLevel::Error => "ERROR",
64+
LogLevel::Warn => "WARN",
65+
LogLevel::Info => "INFO",
66+
LogLevel::Debug => "DEBUG",
67+
};
68+
69+
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
70+
let formatted = format!("[{}] {} - {}\n", timestamp, level_str, message);
71+
72+
// Write to log file if configured
73+
if let Some(ref path) = self.log_file {
74+
if let Some(parent) = path.parent() {
75+
fs::create_dir_all(parent).ok();
76+
}
77+
78+
if let Ok(mut file) = OpenOptions::new()
79+
.create(true)
80+
.append(true)
81+
.open(path)
82+
{
83+
file.write_all(formatted.as_bytes()).ok();
84+
}
85+
}
86+
87+
// Also write to stderr for error level in debug mode
88+
if level == LogLevel::Error || (level == LogLevel::Debug && self.level >= LogLevel::Debug) {
89+
eprint!("{}", formatted);
90+
}
91+
}
92+
}
93+
94+
/// Log an error message
95+
pub fn error(message: &str) {
96+
if let Ok(guard) = LOGGER.lock() {
97+
if let Some(ref logger) = *guard {
98+
logger.log(LogLevel::Error, message);
99+
}
100+
}
101+
}
102+
103+
/// Log a warning message
104+
pub fn warn(message: &str) {
105+
if let Ok(guard) = LOGGER.lock() {
106+
if let Some(ref logger) = *guard {
107+
logger.log(LogLevel::Warn, message);
108+
}
109+
}
110+
}
111+
112+
/// Log an info message
113+
pub fn info(message: &str) {
114+
if let Ok(guard) = LOGGER.lock() {
115+
if let Some(ref logger) = *guard {
116+
logger.log(LogLevel::Info, message);
117+
}
118+
}
119+
}
120+
121+
/// Log a debug message
122+
pub fn debug(message: &str) {
123+
if let Ok(guard) = LOGGER.lock() {
124+
if let Some(ref logger) = *guard {
125+
logger.log(LogLevel::Debug, message);
126+
}
127+
}
128+
}
129+
130+
/// Log a formatted error message
131+
#[macro_export]
132+
macro_rules! log_error {
133+
($($arg:tt)*) => {
134+
$crate::logger::error(&format!($($arg)*))
135+
};
136+
}
137+
138+
/// Log a formatted warning message
139+
#[macro_export]
140+
macro_rules! log_warn {
141+
($($arg:tt)*) => {
142+
$crate::logger::warn(&format!($($arg)*))
143+
};
144+
}
145+
146+
/// Log a formatted info message
147+
#[macro_export]
148+
macro_rules! log_info {
149+
($($arg:tt)*) => {
150+
$crate::logger::info(&format!($($arg)*))
151+
};
152+
}
153+
154+
/// Log a formatted debug message
155+
#[macro_export]
156+
macro_rules! log_debug {
157+
($($arg:tt)*) => {
158+
$crate::logger::debug(&format!($($arg)*))
159+
};
160+
}
161+
162+
#[cfg(test)]
163+
mod tests {
164+
use super::*;
165+
166+
#[test]
167+
fn test_log_level_from_str() {
168+
assert_eq!(LogLevel::from_str("debug"), LogLevel::Debug);
169+
assert_eq!(LogLevel::from_str("DEBUG"), LogLevel::Debug);
170+
assert_eq!(LogLevel::from_str("info"), LogLevel::Info);
171+
assert_eq!(LogLevel::from_str("warn"), LogLevel::Warn);
172+
assert_eq!(LogLevel::from_str("warning"), LogLevel::Warn);
173+
assert_eq!(LogLevel::from_str("error"), LogLevel::Error);
174+
assert_eq!(LogLevel::from_str("off"), LogLevel::Off);
175+
assert_eq!(LogLevel::from_str("none"), LogLevel::Off);
176+
assert_eq!(LogLevel::from_str("unknown"), LogLevel::Info);
177+
}
178+
179+
#[test]
180+
fn test_log_level_ordering() {
181+
assert!(LogLevel::Debug > LogLevel::Info);
182+
assert!(LogLevel::Info > LogLevel::Warn);
183+
assert!(LogLevel::Warn > LogLevel::Error);
184+
assert!(LogLevel::Error > LogLevel::Off);
185+
}
186+
}

thop-rust/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
mod cli;
22
mod config;
33
mod error;
4+
mod logger;
5+
mod mcp;
46
mod session;
7+
mod sshconfig;
58
mod state;
69

710
use std::process::ExitCode;

0 commit comments

Comments
 (0)