Files
shell-rs/src/cli/mod.rs
T
XuzandClaude Opus 5.5 87c5cfb120 堡垒机用 ssh:// 链接打开临时连接(兼容 Xshell 的调用方式)
`ShellRS ssh://用户[:密码]@地址[:端口]` 或 `ShellRS -url … -newtab 名称`
开一个终端标签连过去。只看第一个参数判断是不是链接,外部 CLI 的命令不受
影响;Windows 发布版也认。

- 临时连接不保存:HostStore 把它放在单独的 transient 里,按 id 找得到,
  但不在 hosts() 里,不写库、不进最近连接;终端和 SFTP 标签都关掉就忘掉。
- 链接里的密码只在 TransientSecretStore 的内存里(SecretRef::Transient),
  不进钥匙串。
- ShellRS 已在运行时,Request::Activate 带上链接交给它打开;不带链接时消息
  和以前一字不差,旧版收到也只是调出窗口,所以不升协议版本。
- 链接有问题时弹通知说明原因,文字里不带链接原文。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:24:42 +08:00

302 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The external CLI: the `shellrs` command AI agents use to run commands
//! and move files on the user's saved hosts. The command holds no
//! credentials and opens no database: it hands each request to the running
//! app over a local socket, and the app connects with what it has saved.
mod backend;
mod client;
mod install;
#[cfg(any(windows, test))]
mod install_windows;
mod integration;
mod link;
#[cfg(windows)]
mod pipe_windows;
mod protocol;
mod server;
#[cfg(test)]
mod tests;
use std::{
ffi::OsString,
io::{self, IsTerminal as _, Read as _, Write},
path::PathBuf,
};
use clap::{Parser, Subcommand};
pub use backend::SshCliBackend;
pub use client::{Console, FAILURE_EXIT, PARTIAL_EXIT, activate_running_app};
pub use install::{
AgentKind, BinaryStatus, IntegrationPaths, SKILL, SkillStatus, UserPath, binary_status,
install_binary, install_skill, remove_binary, remove_skill, skill_status,
update_outdated_binary, update_outdated_skills,
};
pub use integration::{CliIntegration, IntegrationStatus};
pub use link::{OpenLink, link_arguments};
pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
pub use server::{CliBackend, CliServer, CliTarget};
const AFTER_HELP: &str = "\
Hosts are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID).
ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on.
Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;
255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).";
/// Run commands and move files on the SSH hosts saved in ShellRS.
///
/// ShellRS holds the passwords and keys and makes the connections; this
/// command never sees them.
#[derive(Debug, Parser)]
#[command(name = "shellrs", version, after_help = AFTER_HELP)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// List the saved hosts.
List {
/// Only hosts whose name, host, user, group or ID matches.
#[arg(short, long)]
query: Option<String>,
/// Print JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
},
/// Run one command on a saved host and print its output.
///
/// Opens a temporary connection, runs the command in the login shell,
/// and closes it. The command gets no stdin. The exit code is the
/// remote command's.
Exec {
/// Host ID, from `shellrs list`.
id: String,
/// One complete remote shell command, quoted as one argument.
#[arg(required_unless_present = "stdin")]
command: Option<String>,
/// Read the command from stdin instead, for commands with quotes,
/// pipes, `$` or several lines.
#[arg(long, conflicts_with = "command")]
stdin: bool,
},
/// Copy a local file or folder to a saved host.
///
/// Like scp: when the destination is an existing directory the source
/// goes inside it under its own name; otherwise the destination is the
/// copy's path. Folders are copied recursively; existing files are
/// overwritten.
Upload {
/// Host ID, from `shellrs list`.
id: String,
/// Local file or folder.
local: PathBuf,
/// Remote destination. `~` is the login directory.
remote: String,
/// Print the summary as JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
},
/// Copy a file or folder from a saved host to this machine.
///
/// Destination rules are scp's, as for `upload`.
Download {
/// Host ID, from `shellrs list`.
id: String,
/// Remote file or folder. `~` is the login directory.
remote: String,
/// Local destination.
local: PathBuf,
/// Print the summary as JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
},
}
/// The command line entry: `args` without the program name. Returns the
/// process exit code.
pub fn main(args: Vec<OsString>) -> i32 {
let cli = match Cli::try_parse_from(std::iter::once(OsString::from("shellrs")).chain(args)) {
Ok(cli) => cli,
Err(error) => {
let _ = error.print();
return error.exit_code();
}
};
let (stdout, stderr) = (io::stdout(), io::stderr());
let json = !stdout.is_terminal();
let stderr_is_terminal = stderr.is_terminal();
let mut stdout = ConsoleText::new(stdout, !json);
let mut stderr = ConsoleText::new(stderr, stderr_is_terminal);
let mut console = Console {
stdout: &mut stdout,
stderr: &mut stderr,
json,
stderr_is_terminal,
};
let code = match request(cli.command, &mut console) {
Ok(request) => client::run(&crate::app::cli_socket_path(), request, &mut console),
Err(code) => code,
};
let _ = stdout.finish();
let _ = stderr.finish();
code
}
/// The request a command line asks for, or the exit code of why it
/// cannot be sent.
fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
Ok(match command {
Command::List { query, json } => {
console.json |= json;
Request::List { query }
}
Command::Exec { id, command, stdin } => {
let command = if stdin {
let mut text = String::new();
if let Err(error) = std::io::stdin().read_to_string(&mut text) {
return Err(
console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}"))
);
}
text
} else {
command.unwrap_or_default()
};
let command = normalize_command(&command);
if command.trim().is_empty() {
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
}
Request::Exec { host: id, command }
}
Command::Upload {
id,
local,
remote,
json,
} => {
console.json |= json;
Request::Upload {
host: id,
source: absolute(local, console)?,
destination: remote,
}
}
Command::Download {
id,
remote,
local,
json,
} => {
console.json |= json;
Request::Download {
host: id,
source: remote,
destination: absolute(local, console)?,
}
}
})
}
/// A command as the remote shell should see it. PowerShell ends every line
/// it pipes to a program with CRLF, and a POSIX shell would take the CR as
/// part of the command; a BOM would be the start of the first word.
fn normalize_command(command: &str) -> String {
command
.strip_prefix('\u{feff}')
.unwrap_or(command)
.replace("\r\n", "\n")
}
/// The app runs in another directory, so local paths travel absolute.
fn absolute(path: PathBuf, console: &mut Console) -> Result<PathBuf, i32> {
std::path::absolute(&path).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&format!("无法解析路径 {}:{error}", path.display()),
)
})
}
/// Standard output or error, made safe for a Windows console: remote output
/// may not be UTF-8 (a GBK server, a binary file), and the Windows console
/// refuses bytes that are not. Invalid bytes become U+FFFD there; a
/// character split across two writes is held until its second half comes.
/// Anywhere else, bytes pass through untouched.
struct ConsoleText<W: Write> {
inner: W,
lossy: bool,
/// The start of a character whose rest has not been written yet.
pending: Vec<u8>,
}
impl<W: Write> ConsoleText<W> {
fn new(inner: W, is_terminal: bool) -> Self {
Self {
inner,
lossy: cfg!(windows) && is_terminal,
pending: Vec::new(),
}
}
/// Write out a character cut off at the very end, and flush.
fn finish(&mut self) -> io::Result<()> {
if !self.pending.is_empty() {
self.pending.clear();
self.inner.write_all("\u{fffd}".as_bytes())?;
}
self.inner.flush()
}
}
impl<W: Write> Write for ConsoleText<W> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.lossy {
return self.inner.write(bytes);
}
self.pending.extend_from_slice(bytes);
let mut text = String::new();
let mut rest: &[u8] = &self.pending;
while !rest.is_empty() {
match std::str::from_utf8(rest) {
Ok(valid) => {
text.push_str(valid);
rest = &[];
}
Err(error) => {
let (valid, after) = rest.split_at(error.valid_up_to());
text.push_str(&String::from_utf8_lossy(valid));
match error.error_len() {
Some(invalid) => {
text.push('\u{fffd}');
rest = &after[invalid..];
}
// The rest of the character is still to come.
None => break,
}
}
}
}
self.pending = rest.to_vec();
self.inner.write_all(text.as_bytes())?;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// The arguments the process was started with, apart from the one macOS
/// adds when launching an app; `None` without any. The command's, unless
/// [`link_arguments`] finds a link to open in them.
pub fn command_line_arguments() -> Option<Vec<OsString>> {
let args: Vec<OsString> = std::env::args_os()
.skip(1)
.filter(|arg| !arg.to_string_lossy().starts_with("-psn_"))
.collect();
(!args.is_empty()).then_some(args)
}