Files
shell-rs/src/cli/mod.rs
T
XuzandClaude Opus 5.5 e09adeefb7 外部 CLI 新增 exec --terminal:在已打开的终端里执行命令
只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。
--terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出
交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和
执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。
外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。

shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:30 +08:00

663 lines
23 KiB
Rust

//! 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;
mod manage;
#[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};
use crate::i18n::t;
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 manage::CliChange;
pub(crate) use manage::{
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
};
pub use protocol::{
AuthChoice, CliError, CredentialDeleted, CredentialDetails, CredentialFields,
CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice,
Reply, Request, RouteDetails, RouteFields, Secret, TerminalContext, TransferCounters,
TransferSummary,
};
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse};
// `--help` is English whatever the interface language, so it names the
// settings it points to in both.
const AFTER_HELP: &str = concat!(
"Hosts are named by the 16-character ID that `shellrs hosts list` prints, ",
"the one ShellRS copies with Copy ID (复制 ID).\n", // i18n: keep
"ShellRS must be running, with Settings → External CLI → Enable external CLI ",
"(设置 → 外部 CLI → 启用外部 CLI) turned on.\n", // i18n: keep
"\n",
"Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;\n",
"255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).",
);
const HOST_FIELDS: &str = "\
Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given.
name Required to create.
host The address (IP or host name). Required to create. Also read as `address`.
port Default 22.
user Default root. A host using a credential logs in as the credential's user.
group A group path such as \"Production/Databases\", made when it is not there; null for the top level.
auth \"password\", \"credential\" or \"no_password\".
credential A credential ID from `shellrs credentials list`, for auth \"credential\" (implied).
password The host's own password, saved to the keychain and never printed; null deletes it.
Changing host, port or user without it keeps the saved one.
route {\"type\": \"direct\"}
{\"type\": \"jump\", \"hosts\": [ID, ...]} (saved hosts, in order)
{\"type\": \"proxy\", \"kind\": \"http\" | \"socks5\", \"host\": ..., \"port\": ...,
\"user\": ..., \"password\": ...}
notes Free text.
What `shellrs hosts show <ID> --json` prints can be edited and passed back as it is.";
const CREDENTIAL_FIELDS: &str = "\
Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given.
name Required to create.
kind \"password\" (the default), \"key\" (implied by a key) or \"agent\".
user Default root.
password A password credential's, saved to the keychain and never printed; null deletes it.
key_path A private key file on this machine (relative to the current directory, or ~/...).
private_key The private key's text instead, for ShellRS to keep in a file of its own.
passphrase The key's passphrase, saved to the keychain; null deletes it.
What `shellrs credentials show <ID> --json` prints can be edited and passed back as it is.";
/// Run commands, move files and manage the SSH hosts saved in ShellRS.
///
/// ShellRS holds the passwords and keys and makes the connections; this
/// command never reads them. One given to it to save goes to the keychain
/// and is never printed back.
#[derive(Debug, Parser)]
#[command(name = "shellrs", version, after_help = AFTER_HELP)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Show, create, change or delete saved hosts.
Hosts {
#[command(subcommand)]
command: HostsCommand,
},
/// Show, create, change or delete saved credentials.
Credentials {
#[command(subcommand)]
command: CredentialsCommand,
},
/// 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 hosts list`.
#[arg(required_unless_present = "json")]
id: Option<String>,
/// One complete remote shell command, quoted as one argument.
#[arg(required_unless_present_any = ["stdin", "json"])]
command: Option<String>,
/// Read the command from stdin instead, for commands with quotes,
/// pipes, `$` or several lines.
#[arg(long, conflicts_with = "command")]
stdin: bool,
/// Read {"host": ID, "command": COMMAND} from stdin and print
/// {"exit_code", "stdout", "stderr"} once the command ends, errors as
/// {"error": {"code", "message"}}. The output is ASCII, everything
/// else escaped: no shell quoting, and no console code page can
/// garble it. {"terminal": true} is the same as --terminal.
#[arg(long, conflicts_with_all = ["id", "command", "stdin"])]
json: bool,
/// Type the command into the host's terminal open in ShellRS
/// instead of logging in again, for a bastion host that allows one
/// login. It sends Ctrl-C first, which interrupts whatever runs
/// there. Its stderr comes as stdout; with --json, the result also
/// has "context": the user, host, folder and shell it ran in. Bash,
/// dash and busybox ash only.
#[arg(long)]
terminal: 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 hosts 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 hosts 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,
},
/// Copy what a local folder holds into a folder on a saved host,
/// skipping files that have not changed.
///
/// The remote folder is made when it is not there (its parent must
/// be). A file of the same size and modification time is left alone;
/// any other is copied over. Only this way: nothing comes back.
Sync {
/// Host ID, from `shellrs hosts list`.
id: String,
/// Local folder, whose contents are copied.
local: PathBuf,
/// Remote folder. `~` is the login directory.
remote: String,
/// First delete what is in the remote folder and not in the local
/// one.
#[arg(long)]
delete: bool,
/// Print the summary as JSON. Implied when stdout is not a terminal.
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
enum HostsCommand {
/// List the saved hosts, and the ones connected to without saving
/// while their tabs are open.
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,
},
/// Show one host in full. Passwords are never shown, only whether one
/// is saved.
Show {
/// Host ID, from `shellrs hosts list`.
id: String,
#[arg(long)]
json: bool,
},
/// Save a new host, described by JSON on stdin.
#[command(after_help = HOST_FIELDS)]
Create {
#[arg(long)]
json: bool,
},
/// Change a saved host: the fields of the JSON on stdin.
#[command(after_help = HOST_FIELDS)]
Update {
/// Host ID, from `shellrs hosts list`.
id: String,
#[arg(long)]
json: bool,
},
/// Delete a saved host, with its port forwarding rules.
Delete {
/// Host ID, from `shellrs hosts list`.
id: String,
/// Delete it even with its tabs open in ShellRS, closing them.
#[arg(long)]
force: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
enum CredentialsCommand {
/// List the credentials.
List {
/// Only credentials whose name, user, kind or ID matches.
#[arg(short, long)]
query: Option<String>,
#[arg(long)]
json: bool,
},
/// Show one credential. Passwords and keys are never shown, only
/// whether they are saved.
Show {
/// Credential ID, from `shellrs credentials list`.
id: String,
#[arg(long)]
json: bool,
},
/// Save a new credential, described by JSON on stdin.
#[command(after_help = CREDENTIAL_FIELDS)]
Create {
#[arg(long)]
json: bool,
},
/// Change a credential: the fields of the JSON on stdin.
#[command(after_help = CREDENTIAL_FIELDS)]
Update {
/// Credential ID, from `shellrs credentials list`.
id: String,
#[arg(long)]
json: bool,
},
/// Delete a credential. The hosts using it log in on their own from
/// then on.
Delete {
/// Credential ID, from `shellrs credentials list`.
id: String,
#[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 {
// In the language the app's window is in, as what the app sends back.
if let Ok(path) = crate::app::settings_path() {
let (settings, _) = crate::settings::SettingsStore::load(path);
crate::i18n::set_locale(settings.settings().language.resolved());
}
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,
exec_json: false,
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::Exec {
id,
command,
stdin,
json,
terminal,
} => {
// From here on, errors are JSON too.
console.exec_json = json;
let request = if json {
exec_request(&read_stdin(console)?)
.map_err(|message| console.error(ErrorCode::BadRequest, &message))?
} else {
ExecJson {
host: id.unwrap_or_default(),
command: if stdin {
read_stdin(console)?
} else {
command.unwrap_or_default()
},
terminal: false,
}
};
let command = normalize_command(&request.command);
if command.trim().is_empty() {
return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty")));
}
let host = request.host;
if terminal || request.terminal {
Request::ExecInTerminal { host, command }
} else {
Request::Exec { host, 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)?,
}
}
Command::Sync {
id,
local,
remote,
delete,
json,
} => {
console.json |= json;
Request::Sync {
host: id,
source: absolute(local, console)?,
destination: remote,
delete,
}
}
Command::Hosts { command } => hosts_request(command, console)?,
Command::Credentials { command } => credentials_request(command, console)?,
})
}
fn hosts_request(command: HostsCommand, console: &mut Console) -> Result<Request, i32> {
Ok(match command {
HostsCommand::List { query, json } => {
console.json |= json;
Request::List { query }
}
HostsCommand::Show { id, json } => {
console.json |= json;
Request::ShowHost { host: id }
}
HostsCommand::Create { json } => {
console.json |= json;
Request::CreateHost {
fields: host_fields(console, "shellrs hosts create < host.json")?,
}
}
HostsCommand::Update { id, json } => {
console.json |= json;
let example = format!("shellrs hosts update {id} < changes.json");
Request::UpdateHost {
fields: host_fields(console, &example)?,
host: id,
}
}
HostsCommand::Delete { id, force, json } => {
console.json |= json;
Request::DeleteHost { host: id, force }
}
})
}
fn credentials_request(command: CredentialsCommand, console: &mut Console) -> Result<Request, i32> {
Ok(match command {
CredentialsCommand::List { query, json } => {
console.json |= json;
Request::ListCredentials { query }
}
CredentialsCommand::Show { id, json } => {
console.json |= json;
Request::ShowCredential { credential: id }
}
CredentialsCommand::Create { json } => {
console.json |= json;
Request::CreateCredential {
fields: credential_fields(console, "shellrs credentials create < credential.json")?,
}
}
CredentialsCommand::Update { id, json } => {
console.json |= json;
let example = format!("shellrs credentials update {id} < changes.json");
Request::UpdateCredential {
fields: credential_fields(console, &example)?,
credential: id,
}
}
CredentialsCommand::Delete { id, json } => {
console.json |= json;
Request::DeleteCredential { credential: id }
}
})
}
/// The JSON object on stdin. Not from a terminal: nothing would say what
/// to type, or that it waits for it.
fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value, i32> {
if io::stdin().is_terminal() {
return Err(console.error(
ErrorCode::BadRequest,
&t!("cli.json.from_terminal", example = example),
));
}
let text = read_stdin(console)?;
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
serde_json::from_str(text).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.not_json", error = error),
)
})
}
fn host_fields(console: &mut Console, example: &str) -> Result<HostFields, i32> {
let value = read_json(console, example)?;
manage::host_fields(value).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.invalid", error = error),
)
})
}
/// A key file named relative to here, or to the home directory, is sent
/// as the absolute path the app can find.
fn credential_fields(console: &mut Console, example: &str) -> Result<CredentialFields, i32> {
let value = read_json(console, example)?;
let mut fields = manage::credential_fields(value).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.invalid", error = error),
)
})?;
if let Some(path) = fields.key_path.take() {
let path = match path.strip_prefix("~") {
Ok(rest) => dirs::home_dir().map(|home| home.join(rest)).unwrap_or(path),
Err(_) => path,
};
fields.key_path = Some(absolute(path, console)?);
}
Ok(fields)
}
fn read_stdin(console: &mut Console) -> Result<String, i32> {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.stdin.unreadable", error = error),
)
})?;
Ok(text)
}
/// What `exec --json` reads.
#[derive(Debug, PartialEq, serde::Deserialize)]
struct ExecJson {
host: String,
command: String,
/// `--terminal`.
#[serde(default)]
terminal: bool,
}
/// What an `exec --json` request asks for.
fn exec_request(text: &str) -> Result<ExecJson, String> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
serde_json::from_str(text).map_err(|error| {
t!(
"cli.exec.bad_json",
form = r#"{"host": ..., "command": ...}"#,
error = error
)
.to_string()
})
}
/// 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,
&t!(
"cli.path.unresolvable",
path = path.display(),
error = error
),
)
})
}
/// 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)
}