在线升级与外部 CLI 的文案接入多语言

- 在线升级:状态行、标题栏按钮、更新对话框、出错原因和「重启会关闭…」改走 t! / tn!,列表用 join_list
- 外部 CLI:命令输出(表头、字段、摘要、进度、错误)、应用回给命令的消息和校验、设置页的 Agent 名称都有中英两版
- --help 与 SKILL.md 提到的界面名称写英文并附中文
- 新增 locales/update.yml、locales/cli.yml,补英文输出的单元测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 22:05:37 +08:00
1 parent 17dc087f52
commit f6286b776c
20 files changed
+1398 -380

No files matched your search

+2 -2
View File
@@ -21,7 +21,7 @@ shellrs download <host-id> <remote-path> <local-path>
shellrs sync <host-id> <local-dir> <remote-dir> [--delete]
```
`<host-id>` is the 16-character `id` from `shellrs hosts list` (the same ID that ShellRS copies with 复制 ID).
`<host-id>` is the 16-character `id` from `shellrs hosts list`, the same ID that ShellRS copies with Copy ID (复制 ID).
For a simple command, pass one complete remote shell command string as `<command>`.
Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone:
@@ -119,7 +119,7 @@ To create or update a host or credential from Windows PowerShell 5.1, pipe the J
Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. Exit code 255 means `shellrs` could not run the command at all.
- `not_running`: ShellRS is not open. Ask the user to open ShellRS.
- `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI.
- `not_enabled`: ask the user to turn on Enable external CLI (启用外部 CLI) in ShellRS under Settings → External CLI (设置 → 外部 CLI).
- `host_not_found`: run `shellrs hosts list` again and use an `id` from its output. `credential_not_found`: the same with `shellrs credentials list`.
- `bad_request` from `create` or `update`: the message says which field is wrong, in the words the ShellRS forms use.
- `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`).
+12 -8
View File
@@ -10,6 +10,7 @@ use std::{
use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliTarget};
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
use crate::i18n::t;
use crate::secrets::{SecretRef, SharedSecretStore};
use crate::sftp::{
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider,
@@ -72,7 +73,7 @@ impl SshCliBackend {
Ok(SftpEvent::Prompt(prompt)) => run.refuse(&commands, &prompt),
Ok(SftpEvent::Disconnected(reason)) => return Err(run.failure(reason)),
Ok(_) => {}
Err(_) => return Err(run.failure("SFTP 连接意外结束".into())),
Err(_) => return Err(run.failure(t!("cli.transfer.sftp_ended").into())),
}
}
let _ = commands.send_blocking(command);
@@ -140,7 +141,7 @@ impl CliBackend for SshCliBackend {
Ok(ExecExit::Signal(signal)) => {
let _ = output(
ExecStream::Stderr,
format!("shellrs: 远程命令被信号 {signal} 终止\n").as_bytes(),
format!("shellrs: {}\n", t!("cli.exec.signal", signal = signal)).as_bytes(),
);
Ok(signal_exit_code(&signal))
}
@@ -190,7 +191,7 @@ impl CliBackend for SshCliBackend {
if !source.is_dir() {
return Err(CliError::new(
ErrorCode::BadRequest,
format!("本地目录不存在:{}", source.display()),
t!("cli.transfer.no_local_folder", path = source.display()),
));
}
let request = UploadRequest::sync(source.to_path_buf(), remote_path(destination)?, delete);
@@ -225,15 +226,15 @@ impl Transfer {
let refused = match prompt.kind() {
ConnectionPromptKind::UnknownHost(_) => CliError::new(
ErrorCode::HostKeyUnknown,
"尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机",
t!("cli.transfer.host_key_unknown"),
),
ConnectionPromptKind::HostKeyChanged(_) => CliError::new(
ErrorCode::HostKeyChanged,
"主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份",
t!("cli.transfer.host_key_changed"),
),
ConnectionPromptKind::Authentication(_) => CliError::new(
ErrorCode::MissingCredential,
"这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码",
t!("cli.transfer.missing_credential"),
),
};
self.refused.get_or_insert(refused);
@@ -264,7 +265,7 @@ impl Transfer {
ErrorCode::TransferFailed,
self.notice
.take()
.unwrap_or_else(|| "传输未完成".to_string()),
.unwrap_or_else(|| t!("cli.transfer.unfinished").to_string()),
)
})),
}
@@ -287,7 +288,10 @@ fn remote_path(path: &str) -> Result<RemotePath, CliError> {
let path = path.trim();
let path = match path {
"" => {
return Err(CliError::new(ErrorCode::BadRequest, "远程路径不能为空"));
return Err(CliError::new(
ErrorCode::BadRequest,
t!("cli.transfer.empty_remote_path"),
));
}
"~" => ".".to_string(),
_ if path.starts_with("~/") => format!(".{}", &path[1..]),
+325 -124
View File
@@ -8,6 +8,7 @@ use std::{
time::Duration,
};
use gpui_kit::SharedString;
use unicode_width::UnicodeWidthStr as _;
use super::link::OpenLink;
@@ -17,6 +18,7 @@ use super::protocol::{
RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json,
};
use crate::host::HostOs;
use crate::i18n::{t, tn};
/// Exit code when the command could not do what was asked at all, as ssh
/// uses it.
@@ -100,25 +102,19 @@ impl Asked {
}
}
/// What an app from before a request was added says about it: it cannot
/// read the request, and serde names what it did not know.
const OLDER_APP: &str =
"正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它";
/// Send `request` to the app listening at `endpoint` and print the answer;
/// the exit code.
pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
let result = connect(endpoint).and_then(|stream| talk(&stream, request, console));
match result {
Ok(code) => code,
Err(Failure::NotRunning) => console.error(
ErrorCode::NotRunning,
"ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」",
),
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, message),
Err(Failure::NotRunning) => {
console.error(ErrorCode::NotRunning, &t!("cli.client.not_running"))
}
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, &message),
Err(Failure::Broken(error)) => console.error(
ErrorCode::ConnectFailed,
&format!("与 ShellRS 的连接意外中断:{error}"),
&t!("cli.client.broken", error = error),
),
}
}
@@ -172,7 +168,7 @@ where
enum Failure {
NotRunning,
/// Not allowed to talk to the app, or not willing to.
Refused(&'static str),
Refused(SharedString),
Broken(io::Error),
}
@@ -182,19 +178,20 @@ impl From<io::Error> for Failure {
}
}
/// What a sandbox that forbids local connections looks like.
const NO_PERMISSION: &str =
"没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信";
#[cfg(unix)]
fn connect(socket: &Path) -> Result<std::os::unix::net::UnixStream, Failure> {
std::os::unix::net::UnixStream::connect(socket).map_err(|error| match error.kind() {
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
_ => error.into(),
})
}
/// What a sandbox that forbids local connections looks like.
fn no_permission() -> SharedString {
t!("cli.client.no_permission")
}
/// Open the app's pipe, and make sure it is the app's before saying
/// anything: the name is the machine's, so someone else could hold it.
#[cfg(windows)]
@@ -203,20 +200,18 @@ fn connect(pipe: &Path) -> Result<std::fs::File, Failure> {
let pipe = pipe_windows::open(pipe).map_err(|error| match error.kind() {
io::ErrorKind::NotFound => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
_ => error.into(),
})?;
if !pipe_windows::owned_by_current_user(&pipe)? {
return Err(Failure::Refused(
"外部 CLI 的管道不属于当前用户,已拒绝连接",
));
return Err(Failure::Refused(t!("cli.client.foreign_pipe")));
}
Ok(pipe)
}
#[cfg(not(any(unix, windows)))]
fn connect(_: &Path) -> Result<std::fs::File, Failure> {
Err(Failure::Refused("此系统暂不支持外部 CLI"))
Err(Failure::Refused(t!("cli.unsupported")))
}
/// Send the request and print the replies until the last one.
@@ -306,7 +301,11 @@ where
code: ErrorCode::BadRequest,
message,
} if message.contains("unknown variant") => {
return Ok(console.error(ErrorCode::VersionMismatch, OLDER_APP));
// What an app from before the request was added
// says: serde names what it did not know.
return Ok(
console.error(ErrorCode::VersionMismatch, &t!("cli.client.older_app"))
);
}
Reply::Error { code, message } => return Ok(console.error(code, &message)),
}
@@ -353,7 +352,7 @@ fn print_json(value: &impl serde::Serialize, console: &mut Console) -> io::Resul
/// Rows lined up in columns, wide characters counted as two.
fn print_table<const N: usize>(
header: [&str; N],
header: [SharedString; N],
rows: &[[String; N]],
console: &mut Console,
) -> io::Result<()> {
@@ -379,7 +378,7 @@ fn print_table<const N: usize>(
}
/// Labelled lines, the labels lined up.
fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<()> {
fn print_fields(fields: &[(SharedString, String)], console: &mut Console) -> io::Result<()> {
let width = fields
.iter()
.map(|(label, _)| label.width())
@@ -392,12 +391,14 @@ fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<
Ok(())
}
fn saved_word(saved: Option<bool>) -> &'static str {
match saved {
Some(true) => "(已保存)",
Some(false) => "(未保存)",
None => "",
}
/// `label` saying whether its password is saved, when that is known.
fn with_saved(label: SharedString, saved: Option<bool>) -> String {
let text = match saved {
Some(true) => t!("cli.show.saved", label = label),
Some(false) => t!("cli.show.not_saved", label = label),
None => label,
};
text.to_string()
}
fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Result<()> {
@@ -408,80 +409,86 @@ fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Re
Asked::Create => {
return writeln!(
console.stdout,
"已创建主机「{}」,ID {}",
host.name, host.id
"{}",
t!("cli.host.created", name = host.name, id = host.id)
);
}
Asked::Update => {
return writeln!(
console.stdout,
"已保存主机「{}」(ID {})",
host.name, host.id
"{}",
t!("cli.host.saved", name = host.name, id = host.id)
);
}
_ => {}
}
let auth = match host.auth {
AuthChoice::Password => format!("密码{}", saved_word(host.password_saved)),
AuthChoice::Credential => {
format!("凭据 {}", host.credential.as_deref().unwrap_or_default())
}
AuthChoice::NoPassword => "无密码".to_string(),
AuthChoice::Password => with_saved(t!("cli.auth.password"), host.password_saved),
AuthChoice::Credential => t!(
"cli.auth.credential",
id = host.credential.as_deref().unwrap_or_default()
)
.to_string(),
AuthChoice::NoPassword => t!("cli.auth.no_password").to_string(),
};
let route = match &host.route {
RouteDetails::Direct => "直接连接".to_string(),
RouteDetails::Jump { hosts } => format!(
"SSH 跳板 {}",
hosts
RouteDetails::Direct => t!("cli.route.direct").to_string(),
RouteDetails::Jump { hosts } => {
let deleted = t!("cli.route.deleted_host");
let hosts = hosts
.iter()
.map(|hop| hop.as_deref().unwrap_or("(已删除的主机)"))
.map(|hop| hop.as_deref().unwrap_or(deleted.as_str()))
.collect::<Vec<_>>()
.join(" → ")
),
.join(" → ");
t!("cli.route.jump", hosts = hosts).to_string()
}
RouteDetails::Proxy {
kind,
host,
port,
user,
password_saved,
} => format!(
"{} {}{host}:{port}{}",
match kind {
ProxyChoice::Http => "HTTP 代理",
ProxyChoice::Socks5 => "SOCKS5 代理",
},
user.as_deref()
} => {
let user = user
.as_deref()
.map(|user| format!("{user}@"))
.unwrap_or_default(),
password_saved
.map(|saved| if saved {
"(密码已保存)"
} else {
"(密码未保存)"
})
.unwrap_or_default()
),
.unwrap_or_default();
let address = format!("{user}{host}:{port}");
let proxy = match kind {
ProxyChoice::Http => t!("cli.route.http", address = address),
ProxyChoice::Socks5 => t!("cli.route.socks5", address = address),
};
let proxy = match password_saved {
Some(true) => t!("cli.route.password_saved", proxy = proxy),
Some(false) => t!("cli.route.password_not_saved", proxy = proxy),
None => proxy,
};
proxy.to_string()
}
};
let mut fields = vec![
("ID", host.id.clone()),
("名称", host.name.clone()),
(t!("cli.field.id"), host.id.clone()),
(t!("cli.field.name"), host.name.clone()),
(
"分组",
t!("cli.field.group"),
if host.temporary {
"(未保存)".to_string()
t!("cli.group.not_saved").to_string()
} else {
host.group.clone().unwrap_or_default()
},
),
("地址", format!("{}@{}:{}", host.user, host.host, host.port)),
("认证", auth),
("连接方式", route),
(
t!("cli.field.address"),
format!("{}@{}:{}", host.user, host.host, host.port),
),
(t!("cli.field.auth"), auth),
(t!("cli.field.route"), route),
];
if let Some(os) = host.os.as_deref().and_then(HostOs::from_stored) {
fields.push(("系统", os.label().to_string()));
fields.push((t!("cli.field.os"), os.label().to_string()));
}
if !host.notes.is_empty() {
fields.push(("备注", host.notes.clone()));
fields.push((t!("cli.field.notes"), host.notes.clone()));
}
print_fields(&fields, console)
}
@@ -490,24 +497,31 @@ fn print_host_deleted(deleted: &HostDeleted, console: &mut Console) -> io::Resul
if console.json {
return print_json(deleted, console);
}
let mut line = format!("已删除主机「{}」", deleted.host.name);
if deleted.forwards > 0 {
line.push_str(&format!(",连同它的 {} 条端口转发规则", deleted.forwards));
}
let name = &deleted.host.name;
let mut line = if deleted.forwards > 0 {
tn!(
"cli.host.deleted_with_forwards",
deleted.forwards,
name = name
)
} else {
t!("cli.host.deleted", name = name)
};
if deleted.jump_users > 0 {
line.push_str(&format!(
";{} 台主机经它跳转,现在那一跳是「已删除的主机」",
deleted.jump_users
));
line = tn!(
"cli.host.deleted_jump_users",
deleted.jump_users,
deleted = line
);
}
writeln!(console.stdout, "{line}")
}
fn kind_label(kind: CredentialKindChoice) -> &'static str {
fn kind_label(kind: CredentialKindChoice) -> SharedString {
match kind {
CredentialKindChoice::Password => "密码",
CredentialKindChoice::Key => "密钥",
CredentialKindChoice::Agent => "SSH Agent",
CredentialKindChoice::Password => t!("cli.kind.password"),
CredentialKindChoice::Key => t!("cli.kind.key"),
CredentialKindChoice::Agent => t!("cli.kind.agent"),
}
}
@@ -516,7 +530,7 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -
return print_json(&credentials, console);
}
if credentials.is_empty() {
return writeln!(console.stderr, "没有匹配的凭据");
return writeln!(console.stderr, "{}", t!("cli.credentials.none"));
}
let rows: Vec<[String; 5]> = credentials
.iter()
@@ -530,7 +544,17 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -
]
})
.collect();
print_table(["ID", "名称", "类型", "用户名", "主机数"], &rows, console)
print_table(
[
t!("cli.column.id"),
t!("cli.column.name"),
t!("cli.column.kind"),
t!("cli.column.user"),
t!("cli.column.hosts"),
],
&rows,
console,
)
}
fn print_credential(
@@ -545,43 +569,52 @@ fn print_credential(
Asked::Create => {
return writeln!(
console.stdout,
"已创建凭据「{}」,ID {}",
credential.name, credential.id
"{}",
t!(
"cli.credential.created",
name = credential.name,
id = credential.id
)
);
}
Asked::Update => {
return writeln!(
console.stdout,
"已保存凭据「{}」(ID {})",
credential.name, credential.id
"{}",
t!(
"cli.credential.saved",
name = credential.name,
id = credential.id
)
);
}
_ => {}
}
let mut fields = vec![
("ID", credential.id.clone()),
("名称", credential.name.clone()),
(t!("cli.field.id"), credential.id.clone()),
(t!("cli.field.name"), credential.name.clone()),
(
"类型",
format!(
"{}{}",
kind_label(credential.kind),
saved_word(credential.password_saved)
),
t!("cli.field.kind"),
with_saved(kind_label(credential.kind), credential.password_saved),
),
("用户名", credential.user.clone()),
(t!("cli.field.user"), credential.user.clone()),
];
if credential.kind == CredentialKindChoice::Key {
let key = match (&credential.key_path, credential.kept) {
(Some(path), _) => path.clone(),
(None, _) => "由 ShellRS 保存".to_string(),
(None, _) => t!("cli.credential.kept_key").to_string(),
};
fields.push(("私钥", key));
fields.push((t!("cli.field.private_key"), key));
if let Some(saved) = credential.passphrase_saved {
fields.push(("口令", if saved { "已保存" } else { "未保存" }.to_string()));
let saved = if saved {
t!("cli.credential.passphrase_saved")
} else {
t!("cli.credential.passphrase_not_saved")
};
fields.push((t!("cli.field.passphrase"), saved.to_string()));
}
}
fields.push(("主机", credential.hosts.join(", ")));
fields.push((t!("cli.field.hosts"), credential.hosts.join(", ")));
print_fields(&fields, console)
}
@@ -589,18 +622,20 @@ fn print_credential_deleted(deleted: &CredentialDeleted, console: &mut Console)
if console.json {
return print_json(deleted, console);
}
let mut line = format!("已删除凭据「{}」", deleted.credential.name);
let mut line = t!("cli.credential.deleted", name = deleted.credential.name);
if !deleted.released.is_empty() {
let names: Vec<&str> = deleted
.released
.iter()
.map(|host| host.name.as_str())
.collect();
line.push_str(&format!(
";{} 台主机改为自己登录:{}",
let separator = t!("common.list.separator");
line = tn!(
"cli.credential.deleted_released",
names.len(),
names.join("、")
));
deleted = line,
hosts = names.join(separator.as_str())
);
}
writeln!(console.stdout, "{line}")
}
@@ -610,7 +645,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
return print_json(&hosts, console);
}
if hosts.is_empty() {
return writeln!(console.stderr, "没有匹配的主机");
return writeln!(console.stderr, "{}", t!("cli.hosts.none"));
}
let rows: Vec<[String; 5]> = hosts
.iter()
@@ -621,7 +656,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
// 临时连接 and 外部连接 alike: an agent needs no telling
// them apart, only that the host is not saved.
if host.temporary {
"(未保存)".to_string()
t!("cli.group.not_saved").to_string()
} else {
host.group.clone().unwrap_or_default()
},
@@ -634,7 +669,17 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
]
})
.collect();
print_table(["ID", "名称", "分组", "地址", "系统"], &rows, console)
print_table(
[
t!("cli.column.id"),
t!("cli.column.name"),
t!("cli.column.group"),
t!("cli.column.address"),
t!("cli.column.os"),
],
&rows,
console,
)
}
/// A sync's skipped items are the ones that had not changed.
@@ -642,20 +687,35 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -
if console.json {
print_json(summary, console)?;
} else {
let mut line = format!(
"已传输 {} 个文件,共 {}",
let mut line = tn!(
"cli.summary.transferred",
summary.files,
format_bytes(summary.bytes)
size = format_bytes(summary.bytes)
);
if summary.skipped > 0 {
let word = if sync { "未变" } else { "跳过" };
line.push_str(&format!(",{word} {} 个", summary.skipped));
line = if sync {
t!(
"cli.summary.unchanged",
summary = line,
count = summary.skipped
)
} else {
t!(
"cli.summary.skipped",
summary = line,
count = summary.skipped
)
};
}
if summary.deleted > 0 {
line.push_str(&format!(",删除 {} 个", summary.deleted));
line = t!(
"cli.summary.deleted",
summary = line,
count = summary.deleted
);
}
if summary.failed > 0 {
line.push_str(&format!(",失败 {} 个", summary.failed));
line = t!("cli.summary.failed", summary = line, count = summary.failed);
}
writeln!(console.stdout, "{line}")?;
for failure in &summary.failures {
@@ -666,13 +726,14 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -
}
fn progress_line(counters: TransferCounters) -> String {
format!(
"{}/{} 个文件,{} / {}",
counters.files,
counters.total_files,
format_bytes(counters.bytes),
format_bytes(counters.total_bytes)
t!(
"cli.progress",
files = counters.files,
total_files = counters.total_files,
bytes = format_bytes(counters.bytes),
total_bytes = format_bytes(counters.total_bytes)
)
.to_string()
}
fn format_bytes(bytes: u64) -> String {
@@ -688,3 +749,143 @@ fn format_bytes(bytes: u64) -> String {
}
format!("{value:.1} {}", UNITS[unit])
}
#[cfg(test)]
mod tests {
use super::*;
/// What `print` writes to stdout, as on a terminal.
fn printed(print: impl FnOnce(&mut Console) -> io::Result<()>) -> String {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
print(&mut Console {
stdout: &mut stdout,
stderr: &mut stderr,
json: false,
exec_json: false,
stderr_is_terminal: false,
})
.unwrap();
String::from_utf8(stdout).unwrap()
}
fn host(name: &str) -> HostDetails {
HostDetails {
id: "Jwg5rHvXCxw89paM".into(),
name: name.into(),
group: None,
host: "10.0.0.9".into(),
port: 22,
user: "root".into(),
auth: AuthChoice::Password,
credential: None,
password_saved: None,
route: RouteDetails::Direct,
notes: String::new(),
os: None,
temporary: false,
}
}
fn deleted_host(forwards: u64, jump_users: u64) -> String {
let deleted = HostDeleted {
host: host("web"),
forwards,
jump_users,
};
printed(|console| print_host_deleted(&deleted, console))
}
fn deleted_credential(released: &[&str]) -> String {
let info = |name: &&str| HostInfo {
id: String::new(),
name: name.to_string(),
group: None,
user: "root".into(),
host: "10.0.0.9".into(),
port: 22,
os: None,
temporary: false,
};
let deleted = CredentialDeleted {
credential: CredentialDetails {
id: String::new(),
name: "deploy".into(),
kind: CredentialKindChoice::Password,
user: "deploy".into(),
key_path: None,
kept: false,
hosts: Vec::new(),
password_saved: None,
passphrase_saved: None,
},
released: released.iter().map(info).collect(),
};
printed(|console| print_credential_deleted(&deleted, console))
}
fn summary(files: u64, skipped: u64, sync: bool) -> String {
let summary = TransferSummary {
files,
bytes: 2048,
skipped,
failed: 1,
deleted: 0,
failures: Vec::new(),
};
printed(|console| print_summary(&summary, sync, console).map(drop))
}
#[test]
fn what_was_done_reads_as_one_line_in_either_language() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("zh-CN");
assert_eq!(deleted_host(0, 0), "已删除主机「web」\n");
assert_eq!(
deleted_host(2, 3),
"已删除主机「web」,连同它的 2 条端口转发规则;\
3 台主机经它跳转,现在那一跳是「已删除的主机」\n"
);
assert_eq!(
deleted_credential(&["web", "db"]),
"已删除凭据「deploy」;2 台主机改为自己登录:web、db\n"
);
assert_eq!(
summary(2, 40, true),
"已传输 2 个文件,共 2.0 KB,未变 40 个,失败 1 个\n"
);
crate::i18n::set_locale("en");
assert_eq!(deleted_host(0, 0), "Deleted host \"web\"\n");
assert_eq!(
deleted_host(1, 1),
"Deleted host \"web\" and its port forwarding rule; \
1 host jumped through it and now has a deleted jump host\n"
);
assert_eq!(
deleted_credential(&["web", "db"]),
"Deleted credential \"deploy\"; 2 hosts now log in on their own: web, db\n"
);
assert_eq!(
summary(1, 4, false),
"Transferred 1 file (2.0 KB), 4 skipped, 1 failed\n"
);
}
#[test]
fn an_english_show_lines_up_its_labels() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("en");
let mut shown = host("web");
shown.password_saved = Some(true);
let shown = printed(|console| print_host(&shown, Asked::Show, console));
assert!(
shown.contains("\nAuthentication Password (saved)\n"),
"{shown}"
);
assert!(shown.contains("\nConnection Direct\n"), "{shown}");
assert!(
shown.starts_with("ID Jwg5rHvXCxw89paM\n"),
"{shown}"
);
}
}
+18 -11
View File
@@ -7,6 +7,10 @@
use std::path::Path;
use std::{fs, io, path::PathBuf};
use gpui_kit::SharedString;
use crate::i18n::t;
/// The skill that teaches an agent to use `shellrs`.
pub const SKILL: &str = include_str!("SKILL.md");
@@ -36,19 +40,19 @@ impl AgentKind {
AgentKind::WorkBuddy,
];
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
AgentKind::Generic => "通用 Agent",
AgentKind::Codex => "Codex",
AgentKind::ClaudeCode => "Claude Code",
AgentKind::OpenCode => "OpenCode",
AgentKind::WorkBuddy => "WorkBuddy",
AgentKind::Generic => t!("cli.agent.generic"),
AgentKind::Codex => "Codex".into(),
AgentKind::ClaudeCode => "Claude Code".into(),
AgentKind::OpenCode => "OpenCode".into(),
AgentKind::WorkBuddy => "WorkBuddy".into(),
}
}
pub fn description(self) -> Option<&'static str> {
pub fn description(self) -> Option<SharedString> {
match self {
AgentKind::Generic => Some("安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。"),
AgentKind::Generic => Some(t!("cli.agent.generic_description")),
_ => None,
}
}
@@ -277,7 +281,7 @@ fn link_binary(paths: &IntegrationPaths) -> io::Result<()> {
#[cfg(not(unix))]
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持安装 shellrs 命令",
t!("cli.install.unsupported").to_string(),
))
}
@@ -300,7 +304,7 @@ fn remove_link(paths: &IntegrationPaths) -> io::Result<()> {
fn occupied(link: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} 已被其他程序占用,ShellRS 不会改动它", link.display()),
t!("cli.install.occupied", path = link.display()).to_string(),
)
}
@@ -323,7 +327,10 @@ fn run_as_administrator(command: &str) -> io::Result<()> {
let message = String::from_utf8_lossy(&output.stderr);
// AppleScript's "User canceled." is error -128.
if message.contains("-128") {
return Err(io::Error::new(io::ErrorKind::Interrupted, "已取消"));
return Err(io::Error::new(
io::ErrorKind::Interrupted,
t!("cli.install.cancelled").to_string(),
));
}
Err(io::Error::other(message.trim().to_string()))
}
+2 -1
View File
@@ -12,6 +12,7 @@ use std::{
};
use super::install::{BinaryStatus, IntegrationPaths, UserPath};
use crate::i18n::t;
pub(super) fn binary_status(paths: &IntegrationPaths) -> BinaryStatus {
let source = fs::metadata(&paths.exe).ok();
@@ -56,7 +57,7 @@ pub(super) fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
if !paths.exe.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("找不到 {}", paths.exe.display()),
t!("cli.install.missing", path = paths.exe.display()).to_string(),
));
}
let folder = folder(paths);
+1 -1
View File
@@ -46,7 +46,7 @@ fn without_password(url: &str) -> String {
}
parsed.to_string()
}
Err(_) => "<无法解析的链接>".into(),
Err(_) => "<unreadable link>".into(),
}
}
+39 -42
View File
@@ -9,6 +9,7 @@ use crate::host::{
AuthKind, Credential, CredentialDraft, CredentialId, CredentialKind, GroupId, Host, HostDraft,
HostId, HostStore, PastedKey, ProxyKind, ProxySettings, Route,
};
use crate::i18n::t;
use crate::secrets::SecretRef;
use super::protocol::{
@@ -202,9 +203,10 @@ pub fn with_saved_credential_secrets(
/// The credential search: its name, user, kind or ID.
pub fn credential_matches(details: &CredentialDetails, query: &str) -> bool {
let needle = query.trim().to_lowercase();
// Both languages' words, whichever the agent or its user types.
let kind = match details.kind {
CredentialKindChoice::Password => "password 密码",
CredentialKindChoice::Key => "key 密钥",
CredentialKindChoice::Password => "password 密码", // i18n: keep
CredentialKindChoice::Key => "key 密钥", // i18n: keep
CredentialKindChoice::Agent => "agent ssh agent",
};
needle.is_empty()
@@ -218,10 +220,17 @@ fn bad(message: impl Into<String>) -> CliError {
CliError::new(ErrorCode::BadRequest, message)
}
fn host_not_found(id: &str) -> CliError {
pub(super) fn host_not_found(id: &str) -> CliError {
CliError::new(
ErrorCode::HostNotFound,
format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"),
t!("cli.error.host_not_found", id = id),
)
}
pub(super) fn credential_not_found(id: &str) -> CliError {
CliError::new(
ErrorCode::CredentialNotFound,
t!("cli.error.credential_not_found", id = id),
)
}
@@ -239,10 +248,7 @@ pub fn find_host<'a>(store: &'a HostStore, id: &str) -> Result<&'a Host, CliErro
pub fn find_saved_host(store: &HostStore, id: &str) -> Result<HostId, CliError> {
let host = find_host(store, id)?;
if store.is_temporary(host.id) {
return Err(bad(format!(
"「{}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可",
host.name
)));
return Err(bad(t!("cli.host.temporary", name = host.name)));
}
Ok(host.id)
}
@@ -252,12 +258,7 @@ pub fn find_credential<'a>(store: &'a HostStore, id: &str) -> Result<&'a Credent
.credentials()
.iter()
.find(|credential| credential.keychain_id.as_str() == id.trim())
.ok_or_else(|| {
CliError::new(
ErrorCode::CredentialNotFound,
format!("没有 ID 为 {id} 的凭据:请用 shellrs credentials list 查看"),
)
})
.ok_or_else(|| credential_not_found(id))
}
/// Where a host goes in the tree.
@@ -359,9 +360,7 @@ pub fn plan_host(
};
let credential = match (fields.auth, named) {
(Some(AuthChoice::Password | AuthChoice::NoPassword), Some(_)) => {
return Err(bad(
"auth 为 password 或 no_password 时不能同时指定 credential",
));
return Err(bad(t!("cli.host.credential_with_own_auth")));
}
(Some(AuthChoice::Password | AuthChoice::NoPassword), None) => None,
(_, Some(named)) => Some(named),
@@ -369,15 +368,12 @@ pub fn plan_host(
(_, None) => draft.credential.and_then(|id| store.credential(id)),
};
match (fields.auth, credential) {
(Some(AuthChoice::Credential), None) => return Err(bad("请选择凭据")),
(Some(AuthChoice::Credential), None) => return Err(bad(t!("cli.host.no_credential"))),
(_, Some(credential)) => {
if let Some(user) = &fields.user
&& user.trim() != credential.user.as_ref()
{
return Err(bad(format!(
"使用凭据的主机以凭据的用户名 {} 登录:去掉 user,或改凭据的用户名",
credential.user
)));
return Err(bad(t!("cli.host.credential_user", user = credential.user)));
}
draft.credential = Some(credential.id);
draft.user = credential.user.clone();
@@ -401,7 +397,7 @@ pub fn plan_host(
}
let own_password = draft.credential.is_none() && draft.auth == AuthKind::Password;
if !own_password && matches!(fields.password, Some(Some(_))) {
return Err(bad("只有 auth 为 password 的主机才保存自己的密码"));
return Err(bad(t!("cli.host.password_without_auth")));
}
// The proxy password field, when the route was given at all.
@@ -419,7 +415,7 @@ pub fn plan_host(
} => {
let user = user.unwrap_or_default();
if user.trim().is_empty() && matches!(password, Some(Some(_))) {
return Err(bad("填写代理密码时请同时填写用户名"));
return Err(bad(t!("cli.host.proxy_password_without_user")));
}
proxy_field = Some(password);
let kind = match kind {
@@ -475,13 +471,13 @@ fn jump_hosts(
};
let hop = find_host(store, id)?;
if store.is_temporary(hop.id) {
return Err(bad(format!("跳板只能是保存的主机:{id} 是没有保存的连接")));
return Err(bad(t!("cli.host.jump_temporary", id = id)));
}
if Some(hop.id) == own {
return Err(bad("跳板主机不能是这台主机自己"));
return Err(bad(t!("cli.host.jump_self")));
}
if hops.contains(&Some(hop.id)) {
return Err(bad(format!("跳板主机 {id} 重复了")));
return Err(bad(t!("cli.host.jump_repeated", id = id)));
}
hops.push(Some(hop.id));
}
@@ -498,14 +494,11 @@ fn resolve_group(store: &HostStore, path: &str) -> Result<GroupPlan, CliError> {
}
let names: Vec<&str> = path.split('/').map(str::trim).collect();
if names.iter().any(|name| name.is_empty()) {
return Err(bad(format!("分组路径「{path}」里有空的一级")));
return Err(bad(t!("cli.group.empty_level", path = path)));
}
let wanted = names.join("/");
let ambiguous = |count: usize, path: &str| {
bad(format!(
"有 {count} 个分组的路径都是「{path}」:请先在 ShellRS 里给它们改名"
))
};
let ambiguous =
|count: usize, path: &str| bad(t!("cli.group.ambiguous", count = count, path = path));
let whole: Vec<GroupId> = store
.groups()
.iter()
@@ -587,22 +580,25 @@ pub fn plan_credential(
draft.user = user.into();
}
if gives_key && kind != CredentialKind::Key {
return Err(bad("只有密钥凭据(kind 为 key)才有私钥"));
return Err(bad(t!("cli.credential.key_without_kind")));
}
if kind != CredentialKind::Password && matches!(fields.password, Some(Some(_))) {
return Err(bad("只有密码凭据(kind 为 password)才保存密码"));
return Err(bad(t!("cli.credential.password_without_kind")));
}
if kind != CredentialKind::Key && matches!(fields.passphrase, Some(Some(_))) {
return Err(bad("只有密钥凭据(kind 为 key)才有口令"));
return Err(bad(t!("cli.credential.passphrase_without_kind")));
}
let private_key = match (fields.key_path, fields.private_key) {
(Some(_), Some(_)) => return Err(bad("key_path 和 private_key 只能给一个")),
(Some(_), Some(_)) => return Err(bad(t!("cli.credential.two_keys"))),
(Some(path), None) => {
if !path.is_absolute() {
return Err(bad(format!("私钥文件必须是绝对路径:{}", path.display())));
return Err(bad(t!(
"cli.credential.key_not_absolute",
path = path.display()
)));
}
if !Path::new(&path).is_file() {
return Err(bad(format!("私钥文件不存在:{}", path.display())));
return Err(bad(t!("cli.credential.key_missing", path = path.display())));
}
draft = draft.with_key_path(path.display().to_string());
None
@@ -612,11 +608,12 @@ pub fn plan_credential(
if store.key_dir().is_none() {
return Err(CliError::new(
ErrorCode::SaveFailed,
"没有可以保存私钥的目录",
t!("cli.credential.no_key_dir"),
));
}
// Stands in until the key file is written, so the draft passes.
draft = draft.with_key_path("(新私钥)");
// Stands in until the key file is written, so the draft passes;
// never saved or shown.
draft = draft.with_key_path("(pasted key)");
Some(pasted)
}
(None, None) => None,
+44 -17
View File
@@ -26,6 +26,8 @@ use std::{
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::{
@@ -48,12 +50,17 @@ pub use protocol::{
};
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse};
const AFTER_HELP: &str = "\
Hosts are named by the 16-character ID that `shellrs hosts 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`).";
// `--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.
@@ -61,7 +68,7 @@ Reads one JSON object from stdin. Every field may be left out; `update` changes
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 \"生产/数据库\", made when it is not there; null for the top level.
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.
@@ -332,7 +339,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
};
let command = normalize_command(&command);
if command.trim().is_empty() {
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty")));
}
Request::Exec { host, command }
}
@@ -450,7 +457,7 @@ fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value,
if io::stdin().is_terminal() {
return Err(console.error(
ErrorCode::BadRequest,
&format!("请从标准输入传入 JSON,例如 {example}"),
&t!("cli.json.from_terminal", example = example),
));
}
let text = read_stdin(console)?;
@@ -458,23 +465,31 @@ fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value,
serde_json::from_str(text).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&format!("标准输入不是 JSON:{error}"),
&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, &format!("JSON 有误:{error}")))
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, &format!("JSON 有误:{error}")))?;
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),
@@ -490,7 +505,10 @@ fn read_stdin(console: &mut Console) -> Result<String, i32> {
std::io::stdin()
.read_to_string(&mut text)
.map_err(|error| {
console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}"))
console.error(
ErrorCode::BadRequest,
&t!("cli.stdin.unreadable", error = error),
)
})?;
Ok(text)
}
@@ -506,7 +524,12 @@ struct ExecJson {
fn exec_request(text: &str) -> Result<(String, String), String> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let request: ExecJson = serde_json::from_str(text).map_err(|error| {
format!("标准输入不是 {{\"host\": ..., \"command\": ...}} 形式的 JSON:{error}")
t!(
"cli.exec.bad_json",
form = r#"{"host": ..., "command": ...}"#,
error = error
)
.to_string()
})?;
Ok((request.host, request.command))
}
@@ -526,7 +549,11 @@ 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()),
&t!(
"cli.path.unresolvable",
path = path.display(),
error = error
),
)
})
}
+15 -24
View File
@@ -21,8 +21,8 @@ use tokio::sync::watch;
use super::link::OpenLink;
use super::manage::{
CliChange, CredentialSecrets, HostSecrets, credential_details, credential_matches,
credential_secrets, host_details, host_info, host_secrets, with_saved_credential_secrets,
with_saved_passwords,
credential_not_found, credential_secrets, host_details, host_info, host_not_found,
host_secrets, with_saved_credential_secrets, with_saved_passwords,
};
use super::protocol::{
CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo,
@@ -30,6 +30,7 @@ use super::protocol::{
read_frame, write_frame, write_json,
};
use crate::host::{Host, HostLogin, HostStore, matches_query};
use crate::i18n::t;
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
@@ -538,12 +539,15 @@ struct Listener;
fn listen(_: PathBuf, _: Arc<Shared>) -> io::Result<Listener> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持外部 CLI",
t!("cli.unsupported").to_string(),
))
}
fn another_app() -> io::Error {
io::Error::new(io::ErrorKind::AddrInUse, "另一个 ShellRS 已在提供外部 CLI")
io::Error::new(
io::ErrorKind::AddrInUse,
t!("cli.server.another_app").to_string(),
)
}
/// Read one request and answer it.
@@ -593,15 +597,14 @@ fn respond(
if version.version != PROTOCOL_VERSION {
return Err(CliError::new(
ErrorCode::VersionMismatch,
"shellrs 命令与正在运行的 ShellRS 版本不同:\
请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI",
t!("cli.server.version_mismatch"),
));
}
let envelope = envelope.map_err(bad_request)?;
if !shared.enabled.load(Ordering::Acquire) {
return Err(CliError::new(
ErrorCode::NotEnabled,
"ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」",
t!("cli.server.not_enabled"),
));
}
if let Some(usage) = CliUse::of(&envelope.request) {
@@ -718,14 +721,7 @@ fn respond(
.iter()
.find(|known| known.details.id == credential.trim())
.cloned()
.ok_or_else(|| {
CliError::new(
ErrorCode::CredentialNotFound,
format!(
"没有 ID 为 {credential} 的凭据:请用 shellrs credentials list 查看"
),
)
})?;
.ok_or_else(|| credential_not_found(&credential))?;
let saved = |secret: &SecretRef| shared.backend.is_saved(secret);
Ok(Reply::Credential(with_saved_credential_secrets(
found.details,
@@ -758,7 +754,7 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
let gone = || {
CliError::new(
ErrorCode::ConnectFailed,
"ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成",
t!("cli.server.closed_during_change"),
)
};
let (sender, outcome) = mpsc::channel();
@@ -786,7 +782,7 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
changes.remove(index);
return Err(CliError::new(
ErrorCode::ConnectFailed,
"ShellRS 没有及时处理这项改动,改动没有做:请稍后再试",
t!("cli.server.change_timed_out"),
));
}
}
@@ -802,17 +798,12 @@ fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
.iter()
.find(|target| target.info.id == id.trim())
.cloned()
.ok_or_else(|| {
CliError::new(
ErrorCode::HostNotFound,
format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"),
)
})
.ok_or_else(|| host_not_found(id))
}
fn not_absolute(path: &Path) -> CliError {
CliError::new(
ErrorCode::BadRequest,
format!("本地路径必须是绝对路径:{}", path.display()),
t!("cli.server.not_absolute", path = path.display()),
)
}