From 9e746add4ba6f57a3b0369c35a21d90278a1c439 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 12 Oct 2026 07:39:48 +0800 Subject: [PATCH 1/2] feat(cli): add English and Chinese i18n and refresh README Extract CLI messages into locale catalogs; add language overrides, regression tests, CI, and bilingual documentation. --- .github/workflows/ci.yml | 36 +++++ Cargo.toml | 2 +- README.md | 176 ++++++++-------------- README.zh-CN.md | 107 +++++++++++++ docs/i18n.md | 65 ++++++++ docs/usage.md | 83 ++++++++++ docs/usage.zh-CN.md | 162 ++++++++++++++++++++ locales/en.json | 114 ++++++++++++++ locales/zh-CN.json | 114 ++++++++++++++ scripts/check-i18n.py | 108 +++++++++++++ scripts/test_i18n_checks.py | 68 +++++++++ src/catalog.rs | 23 ++- src/i18n.rs | 274 +++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 293 +++++++++++++++++++++--------------- src/oauth.rs | 53 +++++-- src/oauth/callback.rs | 53 +++++-- src/plan.rs | 13 +- src/restore.rs | 9 +- tests/i18n.rs | 196 ++++++++++++++++++++++++ 20 files changed, 1671 insertions(+), 279 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 README.zh-CN.md create mode 100644 docs/i18n.md create mode 100644 docs/usage.md create mode 100644 docs/usage.zh-CN.md create mode 100644 locales/en.json create mode 100644 locales/zh-CN.json create mode 100644 scripts/check-i18n.py create mode 100644 scripts/test_i18n_checks.py create mode 100644 src/i18n.rs create mode 100644 tests/i18n.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f951c25 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CLI checks + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: cli-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v2 + - name: Validate translation catalogs + run: | + python3 scripts/check-i18n.py + python3 scripts/test_i18n_checks.py + - name: Check formatting + run: mise exec -- cargo fmt --all --check + - name: Test CLI and library + run: mise exec -- cargo test --locked + - name: Check lints + run: mise exec -- cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Check packaged locale files + run: | + mise exec -- cargo package --list --locked > package-files.txt + grep -Fx 'locales/en.json' package-files.txt + grep -Fx 'locales/zh-CN.json' package-files.txt + grep -Fx 'README.zh-CN.md' package-files.txt diff --git a/Cargo.toml b/Cargo.toml index aeaf9ce..905792a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ homepage = "https://api.lmm.best" readme = "README.md" keywords = ["lmm", "cli", "oauth", "ai"] categories = ["command-line-utilities"] -include = ["Cargo.toml", "Cargo.lock", "README.md", "LICENSE", "src/**", "tests/**", "docs/**", "NOTICE", "third_party/**", "scripts/*.py"] +include = ["Cargo.toml", "Cargo.lock", "README.md", "README.zh-CN.md", "locales/**", "LICENSE", "src/**", "tests/**", "docs/**", "NOTICE", "third_party/**", "scripts/*.py"] [lib] name = "lmm" diff --git a/README.md b/README.md index 42ee8cb..f23fb50 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,107 @@ -# lmm - -**LMM API · 连接模型与工具,让智能自由流动** +
-LMM = **Language Model Mesh**,中文「语言模型网络」。Mesh 表达互联与协作,呼应多模型路由、渠道共享和微服务协作的方向。 +# lmm -这是 [TokenNotIncluded/lmm](https://github.com/TokenNotIncluded/lmm) 独立维护的 Rust CLI。crates.io 包名为 `lmm-cli`,可执行文件名为 `lmm`;父仓库通过子模块锁定版本,CLI 的构建和依赖独立管理。 +**Language Model Mesh, from your terminal.** -**当前是开发预览,已实现服务商预设查询、只读发现、桌面 OAuth 登录和模型目录读取,不是可完成应用接入的产品版本。** -`setup`、安装、同步、更新、恢复、解除接入、卸载和启动目前会明确返回阻塞原因, -不会以空操作返回成功。没有修改第三方软件或生产服务。 +Explore provider presets, inspect local tools, and read your LMM model catalog. -完整产品要求见 [原始需求](docs/requirements.zh-CN.md)。完整需求保留为目标,不因当前实现范围而缩减。 -与 Magpie 的功能对照及下一步任务见 [Magpie 对照](docs/magpie-parity.md)。实现使用 Rust,不包装或启动 Magpie,也不依赖 Go 运行时。 +[![Rust](https://img.shields.io/badge/Rust-1.88%2B-000000?style=flat-square&logo=rust)](Cargo.toml) +[![CLI](https://img.shields.io/badge/CLI-lmm-2563eb?style=flat-square)](#quick-start) +[![Languages](https://img.shields.io/badge/languages-English%20%7C%20简体中文-0f766e?style=flat-square)](#language) +[![License](https://img.shields.io/badge/license-AGPL--3.0-64748b?style=flat-square)](LICENSE) -## 从 crates.io 安装 +**English** · [简体中文](README.zh-CN.md) -```bash -cargo install lmm-cli --version 0.1.0 --locked -lmm --help -``` +[Quick start](#quick-start) · [Commands](#commands) · [Usage guide](docs/usage.md) · [Contribute translations](docs/i18n.md) -服务商预设查询是本仓库的新实现,本轮尚未发布;体验这项功能请使用下文的源码构建命令。 +
-这是开发预览版,功能边界见上文。Cargo 安装需要 Rust 1.88 或更新版本及本机编译工具; -面向无需开发环境用户的二进制安装器尚未提供。 -Linux OAuth 功能另需运行中的 Secret Service;Windows/macOS 使用系统凭据库。 +--- -## 当前可用 +## Quick start -- `lmm presets [关键词] [--json]`:查询 56 项内置服务商预设,离线运行,不读取密钥、不访问服务商。 -- `lmm catalog [关键词]`:搜索明确项目身份的软件目录,分别说明安装、接入、维护能力。 -- `lmm status [软件]`:当前进程 OS/架构、WSL/容器/SSH 线索、PATH 候选文件及显式实例路径检查。 -- `lmm doctor [软件] --report`:只读诊断摘要,移除实例和文件路径,不读取配置内容。 -- `lmm setup [软件] --dry-run`:生成带阻塞原因的预览;无目标且有终端时可选择软件。 -- `--json`:结构化结果;自动禁用交互。`--non-interactive` 同样禁止等待输入。 -- `lmm login`:桌面浏览器 PKCE 登录;凭据保存于系统凭据库。 -- `lmm models [--json]`:获取当前授权模型及价格,不调用模型、不切换配置。 -- `lmm logout`:撤销当前 issuer 下的 CLI 令牌族,成功后删除本地凭据;不影响 Pi/DSH 等其他应用。 +Build the current source with the tools pinned in `mise.toml`: -## 服务商预设 +```sh +git clone https://github.com/TokenNotIncluded/lmm.git +cd lmm +mise install +mise exec -- cargo install --path . --locked -```bash -lmm presets +lmm --help lmm presets deepseek --json +lmm catalog ``` -预设来自 [Magpie 的固定版本](https://github.com/yetone/magpie/blob/d5cae267a727e23f4b6e2b28aa107ecdeac5aa9c/internal/provider/presets.go),是服务商地址及相关元数据的静态目录。它不是已经配置的服务商列表,不代表当前账号有权限或额度,也不代表已完成服务商接入、模型发现或调用验证。端点和模型信息可能随服务商变化,不能作为实时承诺。 +The Cargo package is **`lmm-cli`**; the executable is **`lmm`**. This repository builds independently of the LMM backend. Source builds require Rust and native build tools. Linux OAuth also needs a running Secret Service. -`presets` 查询服务商预设,`catalog` 查询软件目录,`models` 读取 LMM 账号的授权模型;三者分别表示不同内容。 +## Commands -## 登录与模型目录 +| Task | Command | What it does | +| --- | --- | --- | +| Explore providers | `lmm presets [query]` | Search the bundled Magpie snapshot, offline | +| Explore software | `lmm catalog [query]` | Search project identities and software descriptions | +| Inspect a tool | `lmm status astrbot --instance /absolute/path` | Check local path evidence without reading configuration contents | +| Share diagnostics | `lmm doctor astrbot --report --json` | Remove local paths from an incomplete diagnostic report | +| Read account models | `lmm login` → `lmm models --json` | Authorize the CLI, then read models and prices without model calls | +| Preview integration | `lmm setup astrbot --dry-run --json` | Show a blocked plan; do not change software or configuration | -```bash -lmm login -lmm models --json -lmm logout -# 自托管必须明确指定可信的 HTTPS origin,三个命令使用同一 issuer -lmm login --issuer https://lmm.example.com --no-browser -``` +**Development preview.** Software installation, integration writes, sync, updates, restore, disconnection, removal, and launch are not implemented. These commands report blockers instead of claiming success. Catalog entries are not compatibility guarantees. -服务端需要注册 `lmm` 客户端并启用已有 OAuth 服务。使用前应确认目标服务支持这些接口,并验证实际账号的浏览器登录流程。 -CLI 只申请 `catalog:read balance:read` 和同意页显示的分组快照;没有 `models:invoke`、 -MCP 或账号管理权限。CLI 登录成功不代表 AstrBot 或 CC Switch 已授权。 +`presets` lists bundled provider metadata. `catalog` lists software. `models` reads models authorized for your account. These are separate resources. -浏览器回调只监听 `127.0.0.1` 随机端口,等待默认 180 秒,可用 `--timeout 10..600` 调整。 -`--no-browser` 仅关闭自动打开浏览器,**不是设备码登录**:浏览器仍须能访问本机回环端口。 -无图形服务器跨设备授权尚未实现;非交互 `login` 直接返回,不暗中等待授权。 -切换账号先 `logout` 再 `login`;已有 CLI 登录不会被新登录静默覆盖。 +## Language -Windows 使用 Credential Manager,macOS 使用 Keychain,Linux 使用 Secret Service。 -凭据库可能显示系统解锁提示;不可用时明确报错,不使用明文文件后备。 -因此认证命令暂不支持 `--non-interactive`,会在访问凭据库之前退出;`models --json` 只禁用 CLI 文本交互,系统仍可能要求解锁。 -源码构建将 Linux D-Bus 库静态编入, -运行时仍需要可用的 Secret Service;不能将开发机单测当作三个系统的实际凭据库验收。 +English and Simplified Chinese are bundled in the binary. No translation download or extra runtime files are needed. -认证操作使用当前用户状态目录中的无密钥 `oauth.lock`,防止并发登录/刷新/退出。 -Linux 为 `$XDG_STATE_HOME/lmm` 或 `~/.local/state/lmm`,macOS 为 -`~/Library/Application Support/lmm`,Windows 为 `%LOCALAPPDATA%/lmm`。 -Unix 目录/锁文件新建权限分别为 0700/0600;已有不安全目录会被拒绝。 -刷新前将“不可重试”状态存入凭据库,响应丢失或保存失败后不会自动重放旧 refresh token。 -此时需要退出再登录;退出撤销失败保留本地记录供重试,不能报告已完成。 +```sh +lmm --lang en --help +lmm status astrbot --lang zh-CN +LMM_LANG=zh-CN lmm catalog +``` -模型目录只表示账号可见范围,不表示某个软件已经兼容。未知价格保留 null, -价格包含服务端返回的倍率,不重复计算;本命令不执行付费测试。 +Selection order: **`--lang` → `LMM_LANG` → `LC_ALL` → `LC_MESSAGES` → `LANG` → English**. -## 本地软件检查 +Environment values such as `zh_CN.UTF-8` and `zh-Hans` are recognized. Empty values are ignored. Unsupported languages fall back to English. `LC_ALL=C` selects English unless overridden by `LMM_LANG` or `--lang`. -显式实例目前只接受 AstrBot 部署根目录、CC Switch 配置根目录: +JSON field names, status values, identifiers, prices, and timestamps do not change with language. Human-readable descriptions and messages do. Original provider names and upstream data are preserved. -```bash -lmm status astrbot --instance /absolute/path/to/AstrBot -lmm status cc-switch --instance /absolute/path/to/.cc-switch -lmm doctor astrbot --instance /absolute/path/to/AstrBot --report --json -lmm setup astrbot --dry-run --json -``` +## Login -Windows 使用当前 Windows 环境的绝对路径。AstrBot 只检查所选根目录的 -`data/cmd_config.json` 路径;CC Switch 检查 `cc-switch.db` 路径,不打开数据库。 -不自动搜索项目、其他用户、Docker 实例、局域网或远程机器。 -PATH 文件可能是包装脚本或来自其他环境,因此只标为线索;不执行 `--version`。 -符号链接和不可访问项显示 `unknown`,文件缺失只描述被检查的那个位置。 +```sh +lmm login +lmm models --json +lmm logout +``` -目前所有软件目录条目的兼容版本/平台/场景列表为空,均未验证。 -配置管理者、授权状态、实际服务商、模型及真实调用路径不会凭路径推断。 -`status` 的退出码 0 只表示完成线索查询,不能用于判断“软件可使用”。 -`doctor` 尚不能完成语义检查,会返回 3。`status`、`doctor`、`setup --dry-run` 没有网络调用或付费验证。 +Login requests **catalog and balance read access only**. It does not grant model invocation or authorize other applications. Credentials stay in the system credential store; there is no plaintext-file fallback. -`--all` 不把目录视为安装列表;当前没有合格适配器,因此返回空计划与退出码 3。 -`--yes` 不能消除能力缺失,不能代表支付、重新授权或切换服务商的同意。 -单独 `install` 的计划不要求登录。 +A browser must reach the CLI's local callback port. `--no-browser` prints the link but is **not** device-code login. Authentication commands do not support `--non-interactive`; even `models --json` may require the operating system to unlock its credential store. -## 开发 +See the [usage guide](docs/usage.md) for trusted self-hosted issuers, callback timeouts, credential storage, and revocation behavior. -从这个独立仓库的根目录执行,工具版本由 `mise.toml` 固定: +## Develop -```bash -mise install -mise exec -- cargo run --locked -- presets deepseek --json -mise exec -- cargo run --locked -- catalog +```sh +python3 scripts/check-i18n.py +python3 scripts/test_i18n_checks.py +mise exec -- cargo fmt --all --check mise exec -- cargo test --locked mise exec -- cargo clippy --locked --all-targets --all-features -- -D warnings -mise exec -- cargo fmt --all --check mise exec -- cargo build --locked --release ``` -不需要先构建或启动父仓库中的核心、扩展或 Web 服务。独立 Cargo 工程与后端分开锁定依赖,避免将 CLI 发布绑定到后端构建。 -源码开发需要 Rust;面向普通用户的签名二进制、安装器和更新渠道尚待发布, -当前没有声称普通用户已经能“一键安装”。CI 构建产物仅作开发预览,不自动发布。 +[Contributor guide](AGENTS.md) · [Translation guide](docs/i18n.md) · [Product requirements (Chinese)](docs/requirements.zh-CN.md) · [Magpie comparison (Chinese)](docs/magpie-parity.md) -## 退出码与安全边界 +## Exit codes -| 退出码 | 含义 | +| Code | Meaning | | --- | --- | -| 0 | 目录/只读查询已输出;请继续检查各层状态 | -| 2 | 参数不完整、目标未知、无法选择目标或输出失败 | -| 3 | 能力未实现、检查未完成或操作被阻止 | - -`restore_fields` 是内存中的三方字段恢复算法:仅在字段仍等于 LMM 写入值时撤销, -保留用户后续修改;任一冲突使整个目标的计算失败。它拒绝整对象、数组及数组索引恢复。 -它没有连接文件写入、日志、授权或命令执行,不能当作 `lmm restore` 已完成的证据。 -同样,付费检查函数只是客户端规划规则,**不能代替服务端消费限制**。 - -指纹函数只用于有界配置快照的内容比较,不是原子写锁。未来接入必须使用目标软件原生 -事务/管理接口解决并发写入;“比较哈希后覆盖文件”不满足完整并发修改验收。 - -只读环境查询不建立状态目录;OAuth 操作会创建上述锁目录并使用系统凭据库。 -LMM 不复制第三方凭据、不上传遥测、不进行付费调用。 -分享前建议使用 `doctor --report --json`;普通 `status` 会显示本地路径。 +| `0` | A query completed; inspect its reported state before assuming a tool is usable | +| `2` | Invalid or missing arguments, unknown target, selection failure, or output error | +| `3` | Blocked operation, unavailable capability, or incomplete diagnostic check | -## 来源与许可证 +## Attribution -项目按 [AGPL-3.0-only](LICENSE) 发布。服务商预设参考 Magpie 的 MIT 许可源码;来源及保留的许可说明见 [NOTICE](NOTICE)。参考预设数据不意味着具备 Magpie 的全部能力。 +Licensed under [AGPL-3.0-only](LICENSE). Provider preset data is derived from a pinned, MIT-licensed [Magpie](third_party/magpie/NOTICE.md) snapshot. LMM is implemented in Rust and does not wrap or run Magpie. See [NOTICE](NOTICE) for source attribution. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..8e3ac27 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,107 @@ +
+ +# lmm + +**在终端使用 Language Model Mesh。** + +查询服务商预设、检查本地工具、读取 LMM 模型目录。 + +[![Rust](https://img.shields.io/badge/Rust-1.88%2B-000000?style=flat-square&logo=rust)](Cargo.toml) +[![CLI](https://img.shields.io/badge/CLI-lmm-2563eb?style=flat-square)](#开始使用) +[![Languages](https://img.shields.io/badge/languages-English%20%7C%20简体中文-0f766e?style=flat-square)](#语言) +[![License](https://img.shields.io/badge/license-AGPL--3.0-64748b?style=flat-square)](LICENSE) + +[English](README.md) · **简体中文** + +[开始使用](#开始使用) · [命令](#命令) · [使用指南](docs/usage.zh-CN.md) · [贡献翻译](docs/i18n.md) + +
+ +--- + +## 开始使用 + +使用 `mise.toml` 固定的开发工具,从当前源码构建: + +```sh +git clone https://github.com/TokenNotIncluded/lmm.git +cd lmm +mise install +mise exec -- cargo install --path . --locked + +lmm --help +lmm presets deepseek --json +lmm catalog +``` + +Cargo 包名为 **`lmm-cli`**,命令名为 **`lmm`**。本仓库独立构建,不需要先启动 LMM 后端。源码构建需要 Rust 和本机编译工具;Linux 的 OAuth 登录还需要运行中的 Secret Service。 + +## 命令 + +| 用途 | 命令 | 实际行为 | +| --- | --- | --- | +| 查询服务商 | `lmm presets [关键词]` | 离线搜索内置 Magpie 预设快照 | +| 查询软件 | `lmm catalog [关键词]` | 搜索项目名称和用途说明 | +| 检查软件 | `lmm status astrbot --instance /绝对路径` | 检查本地路径线索,不读取配置内容 | +| 分享诊断 | `lmm doctor astrbot --report --json` | 输出未完成的诊断摘要,并移除本地路径 | +| 读取账号模型 | `lmm login` → `lmm models --json` | 授权 CLI,再读取模型与价格,不调用模型 | +| 预览接入 | `lmm setup astrbot --dry-run --json` | 列出计划和阻塞原因,不修改软件或配置 | + +**当前为开发预览。** 安装、接入写入、同步、更新、恢复、解除接入、卸载和启动尚未实现。这些命令会返回阻塞原因,不会把空操作报告为成功。目录条目不代表软件已经通过兼容性验证。 + +`presets` 是服务商预设,`catalog` 是软件目录,`models` 是当前账号获准读取的模型。三者不混用。 + +## 语言 + +英文和简体中文随程序一起打包,不需要联网下载翻译,也不需要额外放置语言文件。 + +```sh +lmm --lang en --help +lmm status astrbot --lang zh-CN +LMM_LANG=zh-CN lmm catalog +``` + +选择顺序:**`--lang` → `LMM_LANG` → `LC_ALL` → `LC_MESSAGES` → `LANG` → 英文**。 + +环境变量支持 `zh_CN.UTF-8`、`zh-Hans` 等常见写法。空值会被忽略,不支持的语言使用英文。`LC_ALL=C` 使用英文,但可被 `LMM_LANG` 或 `--lang` 覆盖。 + +JSON 字段名、状态值、标识符、价格和时间戳不随语言改变;面向人的说明和消息会翻译。服务商原名和上游数据保持原样。 + +## 登录 + +```sh +lmm login +lmm models --json +lmm logout +``` + +CLI 只申请**模型目录和余额读取权限**,不申请模型调用权限,也不替其他应用授权。凭据存入系统凭据库,不会退回到明文文件。 + +浏览器必须能访问 CLI 所在机器的回环端口。`--no-browser` 只显示链接,**不是设备码登录**。认证命令暂不支持 `--non-interactive`;即使使用 `models --json`,系统仍可能要求解锁凭据库。 + +自托管地址、回调等待时间、凭据保存和撤销行为见[使用指南](docs/usage.zh-CN.md)。 + +## 开发 + +```sh +python3 scripts/check-i18n.py +python3 scripts/test_i18n_checks.py +mise exec -- cargo fmt --all --check +mise exec -- cargo test --locked +mise exec -- cargo clippy --locked --all-targets --all-features -- -D warnings +mise exec -- cargo build --locked --release +``` + +[贡献指南](AGENTS.md) · [翻译指南](docs/i18n.md) · [产品需求](docs/requirements.zh-CN.md) · [Magpie 功能对照](docs/magpie-parity.md) + +## 退出码 + +| 退出码 | 含义 | +| --- | --- | +| `0` | 查询已完成;仍需检查各层状态,不能据此认定软件可用 | +| `2` | 参数不完整或无效、目标未知、选择失败或输出失败 | +| `3` | 操作被阻止、能力未实现或诊断未完成 | + +## 来源与许可证 + +项目使用 [AGPL-3.0-only](LICENSE)。服务商预设来自固定版本的 [Magpie](third_party/magpie/NOTICE.md),保留其 MIT 许可与来源说明。LMM 使用 Rust 实现,不包装或启动 Magpie。详见 [NOTICE](NOTICE)。 diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..069ed94 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,65 @@ +# Translations + +[English README](../README.md) · [中文 README](../README.zh-CN.md) + +## Layout + +| File | Purpose | +| --- | --- | +| `locales/en.json` | Complete English catalog and fallback messages | +| `locales/zh-CN.json` | Complete Simplified Chinese catalog | +| `src/i18n.rs` | Embedded catalogs, locale selection, and named placeholders | +| `scripts/check-i18n.py` | Check keys, placeholders, message values, and source references | +| `tests/i18n.rs` | CLI behavior and language-independent machine output | + +Both JSON catalogs are compiled into the binary with `include_str!`. There is no network request or working-directory lookup. They are also included in the Cargo source package. No new runtime dependency is needed. + +## Choose a language + +The global `--lang en` or `--lang zh-CN` flag works before or after a subcommand. Language is selected before command help is built. + +Without the flag, the first nonempty value in `LMM_LANG`, `LC_ALL`, `LC_MESSAGES`, and `LANG` is used. Unknown locales, `C`, and `POSIX` use English. Simplified Chinese aliases include `zh`, `zh_CN.UTF-8`, `zh-SG`, and `zh-Hans`. Traditional Chinese has no catalog yet and falls back to English rather than being mislabeled. + +The selected language is immutable for the lifetime of the CLI, including its callback thread. Library callers can use `i18n::text(Locale::ZhCn, key)` without changing global state. An uninitialized library defaults to English. Tests use explicit locales or child-process environments, not unsafe process-wide environment mutation. + +## Add or edit a message + +Use a stable semantic key in both catalogs. Keep complete sentences together; do not build sentences by joining translated fragments. + +```json +{ + "output.target": "{software}: installation is unconfirmed." +} +``` + +```rust +tr("menu.title") +trf!("output.target", software = target.software) +``` + +Named placeholders can move within a sentence but must have matching names and counts across languages. Do not add formatting specifiers, conversions, or nested templates. Placeholder values are substituted once and are never interpreted as more template text. Escape untrusted paths and server text before passing them to a terminal message, for example with `format!("{:?}", value)`. + +`help.template` is a Clap help template, not a `trf!` message. Preserve its upstream placeholders. Command help and help headings are translated; Clap's own parser validation details (such as invalid-option suggestions and built-in default-value annotations) remain in upstream English. + +## Keep machine output stable + +Translate descriptions, help, notes, blockers, and error messages. Do not translate JSON keys, command names, flags, software/provider IDs, OAuth scope names, outcomes, currencies, numeric values, or timestamps. Preserve original vendor names and imported Magpie data. + +The `catalog` JSON response serializes `purpose` as localized prose. Catalog search checks descriptions in both bundled languages, regardless of the selected display language. OAuth errors include a stable `error_code`; do not match errors by their translated text. + +A missing translation falls back to English, then to its message key. This prevents a missing message from crashing an operation, but it is not a substitute for validation: complete matching catalogs are required by the checks. + +## Check a change + +```sh +python3 scripts/check-i18n.py +python3 scripts/test_i18n_checks.py +mise exec -- cargo fmt --all --check +mise exec -- cargo test --locked +mise exec -- cargo clippy --locked --all-targets --all-features -- -D warnings +mise exec -- cargo package --list --locked +``` + +The package file list must contain both locale catalogs. The Python validator does not compile Rust and is not a replacement for the Cargo checks. + +For another language, add a complete JSON catalog, register its `Locale` variant and aliases, include it in `CATALOGS` and `Locale::ALL`, extend the accepted `--lang` values, and add subprocess tests. Update the documentation and packaging checks in the same change. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..24250f7 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,83 @@ +# Usage guide + +[README](../README.md) · [简体中文](usage.zh-CN.md) + +## Provider presets, software, and account models + +`lmm presets [query]` searches the bundled provider snapshot offline. It does not read credentials, configure providers, query their current models, or test connectivity. The source revision and licenses are recorded under [third_party/magpie](../third_party/magpie/NOTICE.md). Static endpoints and models can become outdated. + +`lmm catalog [query]` searches software project identities and descriptions. All integration compatibility combinations are currently unverified. `lmm models` is different: it reads the model catalog authorized for the signed-in LMM account. + +## Local inspection + +```sh +lmm status astrbot --instance /absolute/path/to/AstrBot +lmm status cc-switch --instance /absolute/path/to/.cc-switch +lmm doctor astrbot --instance /absolute/path/to/AstrBot --report --json +lmm setup astrbot --dry-run --json +``` + +Use an absolute path in the operating system running the CLI. Explicit instance selection is currently implemented only for AstrBot and CC Switch. AstrBot checks the selected root's `data/cmd_config.json` location. CC Switch checks the `cc-switch.db` location without opening the database. + +The CLI does not scan other users, projects, Docker instances, remote systems, or local networks. PATH matches are only evidence; executables are not run, even for `--version`. Symlinks and inaccessible items remain unknown. Missing files describe only the locations that were checked. + +A successful `status` query does not prove the application is installed, configured, authorized, compatible, or able to make a model call. `doctor` reports incomplete diagnostics with exit code `3`. Read-only status, diagnostic, and planning commands do not make network or paid calls and do not create state directories. + +`--report` removes local instance and evidence paths from diagnostic output. Normal status output can contain local paths. Configuration contents are not included. + +## Plans and consent + +No software-write adapter is verified in this preview. Setup, install, sync, update, restore, disconnect, uninstall, and run report blockers rather than pretending that an operation completed. `--all` returns an empty plan and exit code `3`; it does not treat the catalog as an installed-software list. + +`--yes` cannot grant missing permissions, authorize payments, switch providers, or bypass missing implementation. A standalone installation plan does not require login. `--json` disables CLI interaction. `--non-interactive` does not wait for missing input. + +The in-memory `restore_fields` helper reverses only scalar fields still equal to the value written by LMM. Later edits are preserved. Any conflict aborts computation for the entire target. It does not provide the `lmm restore` command with file writes, a journal, or transaction support. Object, array, and array-index restoration need dedicated adapters. + +Configuration fingerprints compare bounded snapshots; they are not atomic write locks. Future writes need the application's native transaction or management interface. Client-side price checks cannot replace spending limits enforced by the server. + +## Browser login + +```sh +lmm login +lmm models --json +lmm logout + +# Use the same trusted issuer for all three commands. +lmm login --issuer https://lmm.example.com --no-browser +``` + +The server must register the `lmm` client and implement the expected OAuth endpoints. The CLI requests `catalog:read balance:read` plus the group snapshot shown on the consent page. It does not request `models:invoke`, MCP access, or account administration. CLI login does not authorize AstrBot, CC Switch, Pi, or another application. + +The callback binds only to `127.0.0.1` on a random port. The default wait is 180 seconds; `--timeout` accepts 10 through 600 seconds. `--no-browser` only disables automatic browser launch: the browser must still reach the same machine's loopback port. It is not device-code login or cross-device authorization for a headless server. + +Login requires an interactive terminal. An existing session is not silently replaced. To change accounts, run `logout` before `login`. + +The callback page follows the CLI language. Receiving a callback is not itself proof that token exchange and credential storage completed; return to the CLI to check the final result. + +## Credentials and concurrent operations + +Windows uses Credential Manager, macOS uses Keychain, and Linux uses Secret Service. A system unlock prompt may appear. There is no plaintext-file fallback. Linux still needs a running Secret Service even though the D-Bus build dependency is vendored. + +Authentication commands reject `--non-interactive` before opening the credential store. `models --json` disables CLI text interaction, but the operating system may still request an unlock. Local fixture tests do not certify real credential-store integration on all three platforms. + +OAuth operations use a secret-free `oauth.lock` under the current user's state directory: + +| System | Location | +| --- | --- | +| Linux | `$XDG_STATE_HOME/lmm` or `~/.local/state/lmm` | +| macOS | `~/Library/Application Support/lmm` | +| Windows | `%LOCALAPPDATA%/lmm` | + +New Unix directories and lock files use `0700` and `0600`. Unsafe existing directories are rejected. Login, refresh, and logout use the same lock to prevent concurrent credential changes. + +Before refreshing, the CLI records a non-retryable state in the credential store. If a response or saved result is lost, it will not replay an old refresh token. Log out and sign in again to recover. If logout revocation fails, the local record remains available for a retry; success is not reported. + +Logout revokes only this issuer's CLI token family and removes local credentials after successful revocation. It does not revoke independent application grants. This version cannot list those grants. + +## Models, prices, and JSON + +The account catalog does not prove a target application's compatibility. Unknown prices remain `null`; prices returned by the server are not multiplied again. This command does not call or select a model and does not run a paid test. + +Language selection translates prose, not numeric data, protocol values, or JSON field names. See [Translations](i18n.md) for the precedence rules and stable error codes. + +LMM does not copy third-party credentials or upload telemetry. Read-only local checks do not create state directories. OAuth operations do use the credential store and lock directory described above. diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md new file mode 100644 index 0000000..8b68f9e --- /dev/null +++ b/docs/usage.zh-CN.md @@ -0,0 +1,162 @@ +# 使用指南 + +[返回 README](../README.zh-CN.md) · [English](usage.md) + +**LMM API · 连接模型与工具,让智能自由流动** + +LMM = **Language Model Mesh**,中文「语言模型网络」。Mesh 表达互联与协作,呼应多模型路由、渠道共享和微服务协作的方向。 + +这是 [TokenNotIncluded/lmm](https://github.com/TokenNotIncluded/lmm) 独立维护的 Rust CLI。crates.io 包名为 `lmm-cli`,可执行文件名为 `lmm`;父仓库通过子模块锁定版本,CLI 的构建和依赖独立管理。 + +**当前是开发预览,已实现服务商预设查询、只读发现、桌面 OAuth 登录和模型目录读取,不是可完成应用接入的产品版本。** +`setup`、安装、同步、更新、恢复、解除接入、卸载和启动目前会明确返回阻塞原因, +不会以空操作返回成功。没有修改第三方软件或生产服务。 + +完整产品要求见 [原始需求](requirements.zh-CN.md)。完整需求保留为目标,不因当前实现范围而缩减。 +与 Magpie 的功能对照及下一步任务见 [Magpie 对照](magpie-parity.md)。实现使用 Rust,不包装或启动 Magpie,也不依赖 Go 运行时。 + +## 从源码安装 + +使用仓库中固定的工具版本构建当前代码: + +```sh +mise install +mise exec -- cargo install --path . --locked +lmm --lang zh-CN --help +``` + +源码构建需要 Rust 和本机编译工具。Linux OAuth 还需要运行中的 Secret Service;Windows 和 macOS 使用系统凭据库。 + +## 语言选择 + +支持 `--lang en`、`--lang zh-CN`。参数可放在子命令前后,也适用于 `--help`。 +优先级为 `--lang`、`LMM_LANG`、`LC_ALL`、`LC_MESSAGES`、`LANG`,最后使用英文。 +环境变量兼容 `zh_CN.UTF-8` 和 `zh-Hans`;空值忽略,未知语言使用英文。 +JSON 字段名、状态值、标识符和数字不变,面向人的说明随语言切换。 +详见[翻译指南](i18n.md)。 + +## 当前可用 + +- `lmm presets [关键词] [--json]`:查询 56 项内置服务商预设,离线运行,不读取密钥、不访问服务商。 +- `lmm catalog [关键词]`:搜索明确项目身份的软件目录,分别说明安装、接入、维护能力。 +- `lmm status [软件]`:当前进程 OS/架构、WSL/容器/SSH 线索、PATH 候选文件及显式实例路径检查。 +- `lmm doctor [软件] --report`:只读诊断摘要,移除实例和文件路径,不读取配置内容。 +- `lmm setup [软件] --dry-run`:生成带阻塞原因的预览;无目标且有终端时可选择软件。 +- `--json`:结构化结果;自动禁用交互。`--non-interactive` 同样禁止等待输入。 +- `lmm login`:桌面浏览器 PKCE 登录;凭据保存于系统凭据库。 +- `lmm models [--json]`:获取当前授权模型及价格,不调用模型、不切换配置。 +- `lmm logout`:撤销当前 issuer 下的 CLI 令牌族,成功后删除本地凭据;不影响 Pi/DSH 等其他应用。 + +## 服务商预设 + +```bash +lmm presets +lmm presets deepseek --json +``` + +预设来自 [Magpie 的固定版本](https://github.com/yetone/magpie/blob/d5cae267a727e23f4b6e2b28aa107ecdeac5aa9c/internal/provider/presets.go),是服务商地址及相关元数据的静态目录。它不是已经配置的服务商列表,不代表当前账号有权限或额度,也不代表已完成服务商接入、模型发现或调用验证。端点和模型信息可能随服务商变化,不能作为实时承诺。 + +`presets` 查询服务商预设,`catalog` 查询软件目录,`models` 读取 LMM 账号的授权模型;三者分别表示不同内容。 + +## 登录与模型目录 + +```bash +lmm login +lmm models --json +lmm logout +# 自托管必须明确指定可信的 HTTPS origin,三个命令使用同一 issuer +lmm login --issuer https://lmm.example.com --no-browser +``` + +服务端需要注册 `lmm` 客户端并启用已有 OAuth 服务。使用前应确认目标服务支持这些接口,并验证实际账号的浏览器登录流程。 +CLI 只申请 `catalog:read balance:read` 和同意页显示的分组快照;没有 `models:invoke`、 +MCP 或账号管理权限。CLI 登录成功不代表 AstrBot 或 CC Switch 已授权。 + +浏览器回调只监听 `127.0.0.1` 随机端口,等待默认 180 秒,可用 `--timeout 10..600` 调整。 +`--no-browser` 仅关闭自动打开浏览器,**不是设备码登录**:浏览器仍须能访问本机回环端口。 +无图形服务器跨设备授权尚未实现;非交互 `login` 直接返回,不暗中等待授权。 +切换账号先 `logout` 再 `login`;已有 CLI 登录不会被新登录静默覆盖。 + +Windows 使用 Credential Manager,macOS 使用 Keychain,Linux 使用 Secret Service。 +凭据库可能显示系统解锁提示;不可用时明确报错,不使用明文文件后备。 +因此认证命令暂不支持 `--non-interactive`,会在访问凭据库之前退出;`models --json` 只禁用 CLI 文本交互,系统仍可能要求解锁。 +源码构建将 Linux D-Bus 库静态编入, +运行时仍需要可用的 Secret Service;不能将开发机单测当作三个系统的实际凭据库验收。 + +认证操作使用当前用户状态目录中的无密钥 `oauth.lock`,防止并发登录/刷新/退出。 +Linux 为 `$XDG_STATE_HOME/lmm` 或 `~/.local/state/lmm`,macOS 为 +`~/Library/Application Support/lmm`,Windows 为 `%LOCALAPPDATA%/lmm`。 +Unix 目录/锁文件新建权限分别为 0700/0600;已有不安全目录会被拒绝。 +刷新前将“不可重试”状态存入凭据库,响应丢失或保存失败后不会自动重放旧 refresh token。 +此时需要退出再登录;退出撤销失败保留本地记录供重试,不能报告已完成。 + +模型目录只表示账号可见范围,不表示某个软件已经兼容。未知价格保留 null, +价格包含服务端返回的倍率,不重复计算;本命令不执行付费测试。 + +## 本地软件检查 + +显式实例目前只接受 AstrBot 部署根目录、CC Switch 配置根目录: + +```bash +lmm status astrbot --instance /absolute/path/to/AstrBot +lmm status cc-switch --instance /absolute/path/to/.cc-switch +lmm doctor astrbot --instance /absolute/path/to/AstrBot --report --json +lmm setup astrbot --dry-run --json +``` + +Windows 使用当前 Windows 环境的绝对路径。AstrBot 只检查所选根目录的 +`data/cmd_config.json` 路径;CC Switch 检查 `cc-switch.db` 路径,不打开数据库。 +不自动搜索项目、其他用户、Docker 实例、局域网或远程机器。 +PATH 文件可能是包装脚本或来自其他环境,因此只标为线索;不执行 `--version`。 +符号链接和不可访问项显示 `unknown`,文件缺失只描述被检查的那个位置。 + +目前所有软件目录条目的兼容版本/平台/场景列表为空,均未验证。 +配置管理者、授权状态、实际服务商、模型及真实调用路径不会凭路径推断。 +`status` 的退出码 0 只表示完成线索查询,不能用于判断“软件可使用”。 +`doctor` 尚不能完成语义检查,会返回 3。`status`、`doctor`、`setup --dry-run` 没有网络调用或付费验证。 + +`--all` 不把目录视为安装列表;当前没有合格适配器,因此返回空计划与退出码 3。 +`--yes` 不能消除能力缺失,不能代表支付、重新授权或切换服务商的同意。 +单独 `install` 的计划不要求登录。 + +## 开发 + +从这个独立仓库的根目录执行,工具版本由 `mise.toml` 固定: + +```bash +mise install +mise exec -- cargo run --locked -- presets deepseek --json +mise exec -- cargo run --locked -- catalog +mise exec -- cargo test --locked +mise exec -- cargo clippy --locked --all-targets --all-features -- -D warnings +mise exec -- cargo fmt --all --check +mise exec -- cargo build --locked --release +``` + +不需要先构建或启动父仓库中的核心、扩展或 Web 服务。独立 Cargo 工程与后端分开锁定依赖,避免将 CLI 发布绑定到后端构建。 +源码开发需要 Rust;面向普通用户的签名二进制、安装器和更新渠道尚待发布, +当前没有声称普通用户已经能“一键安装”。CI 构建产物仅作开发预览,不自动发布。 + +## 退出码与安全边界 + +| 退出码 | 含义 | +| --- | --- | +| 0 | 目录/只读查询已输出;请继续检查各层状态 | +| 2 | 参数不完整、目标未知、无法选择目标或输出失败 | +| 3 | 能力未实现、检查未完成或操作被阻止 | + +`restore_fields` 是内存中的三方字段恢复算法:仅在字段仍等于 LMM 写入值时撤销, +保留用户后续修改;任一冲突使整个目标的计算失败。它拒绝整对象、数组及数组索引恢复。 +它没有连接文件写入、日志、授权或命令执行,不能当作 `lmm restore` 已完成的证据。 +同样,付费检查函数只是客户端规划规则,**不能代替服务端消费限制**。 + +指纹函数只用于有界配置快照的内容比较,不是原子写锁。未来接入必须使用目标软件原生 +事务/管理接口解决并发写入;“比较哈希后覆盖文件”不满足完整并发修改验收。 + +只读环境查询不建立状态目录;OAuth 操作会创建上述锁目录并使用系统凭据库。 +LMM 不复制第三方凭据、不上传遥测、不进行付费调用。 +分享前建议使用 `doctor --report --json`;普通 `status` 会显示本地路径。 + +## 来源与许可证 + +项目按 [AGPL-3.0-only](../LICENSE) 发布。服务商预设参考 Magpie 的 MIT 许可源码;来源及保留的许可说明见 [NOTICE](../NOTICE)。参考预设数据不意味着具备 Magpie 的全部能力。 diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..c1f310f --- /dev/null +++ b/locales/en.json @@ -0,0 +1,114 @@ +{ + "arg.activate": "Request adding and selecting LMM; this does not mean the plan was applied", + "arg.add_only": "Add LMM without changing the current selection", + "arg.all": "Use only installed, supported targets; updates also require management permission", + "arg.dry_run": "Create a plan without making changes", + "arg.fix": "Request an authorized LMM configuration repair; do not bypass capability checks", + "arg.instance": "Explicit local instance root; do not scan remote or Docker instances", + "arg.issuer": "Trusted LMM HTTPS origin; specify explicitly for self-hosted servers", + "arg.json": "Print structured JSON without CLI interaction or configuration contents", + "arg.lang": "Interface language: en or zh-CN; otherwise use the locale environment, then English", + "arg.no_browser": "Print the login link; the browser must still reach this machine through loopback", + "arg.non_interactive": "Do not wait for input when a target or authorization is missing", + "arg.report": "Create a shareable summary without paths or configuration contents", + "arg.software": "Exact software ID, such as astrbot or cc-switch", + "arg.timeout": "Browser callback timeout in seconds", + "arg.yes": "Confirm the shown scope, not account changes, provider switches, or paid tests", + "blocker.adapter": "This version has no verified write adapter for the target software.", + "blocker.application_grant": "CLI login permits catalog and balance reads only. Independent authorization for target applications is not yet implemented.", + "blocker.compatibility": "The software version, configuration manager, and actual request path have not been verified.", + "blocker.install": "A verified software version, verified download source, and installation adapter are required.", + "blocker.restore": "The target change journal and native transaction adapter are not connected. A backup cannot be used to overwrite the target.", + "callback.invalid": "Invalid or denied authorization. Return to LMM CLI.", + "callback.received": "Authorization received. Return to LMM CLI to check completion.", + "catalog.entry": "{id} — {name}\n {purpose}\n Source: {source}\n Installation/integration: unavailable. Maintenance: local inspection only. Versions/platforms/scenarios: unverified.\n", + "cli.about": "LMM CLI - provider presets, local inspection, and read-only OAuth", + "command.catalog": "Search the software catalog; entries do not prove compatibility", + "command.disconnect": "Plan disconnection and revocation for one application", + "command.doctor": "Run read-only diagnostics; --fix still requires a verified adapter and permission", + "command.install": "Plan software installation without requiring LMM login", + "command.login": "Sign in with a browser and store credentials in the system credential store", + "command.logout": "Sign out of the CLI without revoking independent application grants", + "command.models": "Read authorized models and prices without calling or selecting a model", + "command.presets": "Search built-in provider presets without reading configuration, credentials, or the network", + "command.restore": "Plan field-level restoration while preserving later user changes", + "command.run": "Plan to start the selected software with its current configuration", + "command.setup": "Plan integration; writes remain blocked until a verified adapter is available", + "command.status": "Inspect the local environment, installation evidence, and verification status", + "command.sync": "Plan an LMM sync while preserving the current provider and model", + "command.uninstall": "Plan removal of managed software; retain data by default", + "command.update": "Plan updates for software explicitly managed by LMM", + "error.absolute_instance": "--instance must be an absolute local path. Remote and container adapters are not available.", + "error.auth_non_interactive": "The system credential store may require interactive unlocking. OAuth commands do not support --non-interactive. No credentials or network were accessed.", + "error.catalog_output": "Cannot write the software catalog.", + "error.json_output": "Cannot write JSON output.", + "error.login_interactive": "Login requires browser authorization. Run lmm login in an interactive terminal. Non-interactive mode will not wait or give consent for you.", + "error.login_output": "Cannot write the login result.", + "error.logout_output": "Cannot write the logout result.", + "error.menu_input": "Cannot read the selection.", + "error.menu_output": "Cannot display the selection menu.", + "error.models_output": "Cannot write the model catalog.", + "error.no_selection": "No target selected. No changes were made.", + "error.output": "Cannot write the result.", + "error.presets_output": "Cannot write provider presets.", + "error.presets_read": "Cannot read the built-in provider presets.", + "error.target_required": "Non-interactive mode requires a software ID or --all. No changes were made.", + "error.unknown_software": "Unknown software ID. Run lmm catalog for exact names.", + "error.unsupported_instance": "Instance selection is not implemented for this software. --instance cannot be ignored.", + "help.arguments": "Arguments", + "help.commands": "Commands", + "help.flag": "Print help", + "help.options": "Options", + "help.subcommand": "Print this message or help for a subcommand", + "help.template": "{before-help}{about-with-newline}\nUsage: {usage}\n\n{all-args}{after-help}", + "help.version": "Print version", + "login.authorize": "Authorize LMM CLI in your browser (catalog and balance read access only; no application migration):\n{url}", + "login.browser_failed": "The browser could not open automatically. Open the link above in a browser on this machine.", + "login.success": "LMM CLI is signed in. Credentials are in the system credential store. No application invocation permission was granted. Run lmm models to read the catalog.", + "logout.success": "The CLI is signed out. Other application grants were not revoked and cannot yet be listed by this version.", + "menu.prompt": "Enter a number, or press Enter to cancel: ", + "menu.title": "Select software to plan an integration. No integration has been verified yet:", + "models.entry": "{id} · {upstream} · {group}\n Price unit: {currency} / {unit}; input: {input}, output: {output}, per request: {request}; basis: {basis}\n null/None means unknown. No model was called or selected.", + "models.title": "Authorized models. This does not prove application compatibility; final prices depend on usage:", + "note.all": "No automatic integration adapter has been verified. --all has no executable targets. No software was installed or changed.", + "note.doctor": "Diagnostics are incomplete. Only local path evidence was checked. Verify the target instance, version, and configuration manager. No network or paid tests were performed.", + "note.dry_run": "Plan only. Blockers remain; this is not proof of a completed integration.", + "note.execution": "A safe execution adapter is not implemented for this target. No software was started or removed, no configuration was changed, and no grant was revoked.", + "note.fix": "No repair adapter has been verified. No configuration was changed.", + "note.install": "Installation does not require LMM login. A verified installation adapter is not yet available.", + "note.yes": "--yes does not bypass missing capabilities, authorization, or cost limits.", + "oauth.already_logged_in": "A CLI session already exists. Run lmm logout before changing accounts. Application grants will not be migrated.", + "oauth.busy": "Another LMM authorization operation is running, or a secure lock for this user could not be created.", + "oauth.callback": "Cannot bind a local OAuth callback port.", + "oauth.credential_store": "Cannot access the system credential store. Unlock Keychain, Credential Manager, or Secret Service. Plaintext files will not be used.", + "oauth.denied": "Authorization was cancelled or denied.", + "oauth.invalid_issuer": "The issuer must be a canonical HTTPS domain origin without a port, path, query, or credentials.", + "oauth.invalid_response": "The LMM OAuth response did not match the protocol. Authorization stopped.", + "oauth.network": "The LMM network request failed. Check the connection and that the server has enabled the lmm client.", + "oauth.not_logged_in": "Not signed in. Run lmm login first.", + "oauth.output": "Cannot display the login link. Login stopped.", + "oauth.refresh_blocked": "The previous refresh did not complete reliably. The old refresh token will not be replayed. Run lmm logout, then lmm login.", + "oauth.timeout": "Login timed out. No further requests were made.", + "oauth.unauthorized": "The server denied authorization. Sign in again or check that the lmm client is deployed.", + "oauth.unstored_grant": "The new grant could not be stored, and revocation could not be confirmed. Check the LMM CLI grant on the LMM authorization page.", + "observation.not_found": "not found", + "observation.present": "present", + "observation.unknown": "unknown", + "output.blocked": "\n{software}: plan blocked. No configuration changes, paid calls, or restarts.", + "output.evidence": " {path}: {observation} (path evidence only)", + "output.scope": "Scope: current user and runtime environment. Checked at: {timestamp} (Unix seconds).", + "output.target": "\n{software}: installation is unconfirmed; installation and configuration managers are unknown.", + "output.verification": " LMM added/enabled: unknown. Authorization, connectivity, application calls, streaming, and tool calls: not checked.", + "plan.paid_verification": "Paid verification requires explicit consent, a known quote, and a spending limit enforced by the server.", + "plan.revoked_grant": "The application grant was revoked. Explicit consent is required to authorize it again.", + "plan.unknown_selection": "The current provider is unknown. Read the selected instance configuration first.", + "presets.title": "Built-in provider presets (Magpie snapshot {revision}). Configuration and connectivity were not checked. No provider was added.", + "restore.conflict": "The field changed after the LMM edit. Restoration is paused.", + "restore.duplicate_field": "The change journal contains duplicate fields.", + "restore.invalid_pointer": "Cannot locate the parent object of the field to restore.", + "restore.unsupported_shape": "Only individual scalar fields in objects are supported. Collections require a dedicated adapter.", + "software.astrbot": "Agents and bots for messaging platforms", + "software.cc_switch": "Provider configuration management and local routing", + "software.coding_cli": "Terminal coding assistant", + "software.pi": "Terminal agent; an LMM provider is maintained as a separate project" +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json new file mode 100644 index 0000000..c700b8d --- /dev/null +++ b/locales/zh-CN.json @@ -0,0 +1,114 @@ +{ + "arg.activate": "明确请求添加并切换到 LMM(不表示已执行)", + "arg.add_only": "仅添加 LMM,保留当前选择", + "arg.all": "仅包含当前环境中已安装且受支持的目标;更新还要求管理授权", + "arg.dry_run": "只生成计划,不执行修改", + "arg.fix": "请求修复已获允许的 LMM 配置;不会绕过能力检查", + "arg.instance": "明确的本地实例根目录;不自动扫描远程或 Docker 实例", + "arg.issuer": "信任的 LMM HTTPS origin(自托管时显式指定)", + "arg.json": "输出结构化 JSON;不交互、不输出配置内容", + "arg.lang": "界面语言:en 或 zh-CN;默认读取语言环境,未知语言使用英文", + "arg.no_browser": "只显示登录链接;仍需要浏览器能访问当前机器的回环端口", + "arg.non_interactive": "无人值守:缺少目标或授权时直接返回", + "arg.report": "生成可主动分享的摘要,去除路径和配置内容", + "arg.software": "精确的软件目录 ID,例如 astrbot 或 cc-switch", + "arg.timeout": "浏览器回调等待上限(秒)", + "arg.yes": "确认已展示范围;不代表允许切换账号、服务商或付费测试", + "blocker.adapter": "此版本尚无经过验证的目标软件写入适配器", + "blocker.application_grant": "CLI 登录只允许读取目录和余额;目标应用独立授权尚未接入", + "blocker.compatibility": "未验证软件版本、配置管理关系及实际调用路径", + "blocker.install": "缺少经过验证的软件版本、下载来源校验及安装适配器", + "blocker.restore": "尚未接入目标的变更日志与原生事务适配器,不能直接覆盖备份", + "callback.invalid": "授权无效或已被拒绝。请返回 LMM CLI。", + "callback.received": "已收到授权。请返回 LMM CLI 检查是否完成。", + "catalog.entry": "{id} — {name}\n {purpose}\n 来源:{source}\n 安装/接入:尚未支持;维护:仅本地线索检查;兼容版本/平台/场景:未验证\n", + "cli.about": "LMM 安装与接入工具(开发预览:环境检查与操作规划)", + "command.catalog": "搜索软件目录;目录条目不代表已验证兼容", + "command.disconnect": "解除指定软件接入并撤销对应授权", + "command.doctor": "只读诊断;--fix 仍受适配器能力与确认范围限制", + "command.install": "安装软件;不要求 LMM 登录", + "command.login": "通过浏览器登录,凭据保存在系统凭据库", + "command.logout": "退出 CLI;不撤销独立应用授权", + "command.models": "读取当前授权模型与计费信息,不发起模型调用或更改选择", + "command.presets": "搜索内置服务商预设快照;不读取配置、凭据或网络", + "command.restore": "按字段恢复 LMM 变更,保留后续修改", + "command.run": "按当前配置启动指定软件", + "command.setup": "规划完整接入;当前版本在未具备适配器时明确阻止写入", + "command.status": "只读检查环境、安装线索与各层验证状态", + "command.sync": "同步 LMM 信息,保留当前服务商与模型", + "command.uninstall": "卸载已确认纳入管理的软件,默认保留数据", + "command.update": "更新明确纳入管理的软件", + "error.absolute_instance": "--instance 必须是当前环境的绝对本地路径;远程与容器适配器尚未提供", + "error.auth_non_interactive": "当前系统凭据库可能需要解锁交互;OAuth 命令尚不支持 --non-interactive,未访问凭据或网络", + "error.catalog_output": "无法输出目录", + "error.json_output": "无法输出 JSON", + "error.login_interactive": "登录需要浏览器授权;请在交互终端运行 lmm login。无人值守模式不会等待或代替用户同意", + "error.login_output": "无法输出登录结果", + "error.logout_output": "无法输出退出结果", + "error.menu_input": "无法读取选择", + "error.menu_output": "无法显示选择菜单", + "error.models_output": "无法输出模型", + "error.no_selection": "未选择目标;没有执行任何修改", + "error.output": "无法输出结果", + "error.presets_output": "无法输出服务商预设", + "error.presets_read": "内置服务商预设无法读取", + "error.target_required": "无人值守模式需要明确软件或 --all;没有执行任何修改", + "error.unknown_software": "未知软件 ID;运行 lmm catalog 查看精确名称", + "error.unsupported_instance": "此软件尚未实现实例选择,不能忽略 --instance 后继续操作", + "help.arguments": "参数", + "help.commands": "命令", + "help.flag": "显示帮助", + "help.options": "选项", + "help.subcommand": "显示此信息或指定命令的帮助", + "help.template": "{before-help}{about-with-newline}\n用法:{usage}\n\n{all-args}{after-help}", + "help.version": "显示版本", + "login.authorize": "请在浏览器确认 LMM CLI 授权(仅目录和余额读取,不迁移任何应用):\n{url}", + "login.browser_failed": "浏览器未能自动打开,请在本机浏览器打开上述链接。", + "login.success": "LMM CLI 已登录,凭据已保存到系统凭据库。尚未授予应用调用权限;可运行 lmm models。", + "logout.success": "CLI 已退出;其他应用授权未撤销,当前版本尚不能列出这些授权。", + "menu.prompt": "输入序号,或直接回车取消:", + "menu.title": "选择要规划接入的软件(当前均未完成集成验证):", + "models.entry": "{id} · {upstream} · {group}\n 价格单位:{currency} / {unit};输入:{input},输出:{output},每次请求:{request};依据:{basis}\n null/None 表示未知;未调用或切换模型。", + "models.title": "当前授权模型(不代表目标软件兼容;价格可能随使用变化):", + "note.all": "当前版本没有经过验证的自动接入适配器,--all 没有可执行目标;未安装或修改任何软件", + "note.doctor": "诊断未完成:仅检查本地路径线索;请核对目标实例、版本及配置管理关系。没有执行网络或付费测试", + "note.dry_run": "仅规划;阻塞项仍需解决,不能作为接入完成的证据", + "note.execution": "未实现该目标的安全执行适配器;未启动或卸载软件,未修改配置或撤销授权", + "note.fix": "当前没有经过验证的修复适配器;未修改配置", + "note.install": "安装本身不需要 LMM 登录;当前缺少经过验证的安装适配器", + "note.yes": "--yes 不会跳过缺失能力、授权或费用限制", + "oauth.already_logged_in": "已有 CLI 登录;切换账号请先 lmm logout,不会迁移应用授权", + "oauth.busy": "另一项 LMM 授权操作正在运行,或无法建立当前用户的安全锁", + "oauth.callback": "无法绑定本机 OAuth 回调端口", + "oauth.credential_store": "无法访问系统凭据库;请解锁 Keychain、Credential Manager 或 Secret Service,不会回退到明文文件", + "oauth.denied": "授权已取消或被拒绝", + "oauth.invalid_issuer": "issuer 必须是规范的 HTTPS 域名 origin,不含端口、路径、查询或凭据", + "oauth.invalid_response": "LMM OAuth 响应不符合约定,已停止授权", + "oauth.network": "LMM 网络请求失败;请检查网络和服务端是否已启用 lmm 客户端", + "oauth.not_logged_in": "尚未登录;请先运行 lmm login", + "oauth.output": "无法输出登录入口;登录已停止", + "oauth.refresh_blocked": "上次刷新未可靠完成;不会重放旧刷新令牌,请先 logout 再 login", + "oauth.timeout": "登录等待超时;没有继续发起请求", + "oauth.unauthorized": "服务端返回未授权;请重新登录,或检查 lmm 客户端是否已部署", + "oauth.unstored_grant": "新授权未能保存,且无法确认已撤销;请在 LMM 授权页检查 LMM CLI 授权", + "observation.not_found": "未找到", + "observation.present": "存在", + "observation.unknown": "未知", + "output.blocked": "\n{software} 计划已阻止:无配置修改、无付费调用、无重启", + "output.evidence": " {path}: {observation}(仅路径线索)", + "output.scope": "范围:当前用户、当前运行环境;检查时间:{timestamp}(Unix 秒)", + "output.target": "\n{software}:安装状态无法确认;安装管理者、配置管理者均未知", + "output.verification": " LMM 添加/启用:未知;授权、连通性、目标调用、流式及工具调用:未检查", + "plan.paid_verification": "付费验证需要明确同意、已知报价和服务端可执行的消费上限", + "plan.revoked_grant": "应用授权已撤销;需要明确确认重新授权", + "plan.unknown_selection": "无法确认当前服务商;需要先读取选定实例的实际配置", + "presets.title": "内置服务商预设(Magpie 快照 {revision});配置与连通性未检查,未添加服务商。", + "restore.conflict": "字段在 LMM 修改后再次变化,恢复已暂停", + "restore.duplicate_field": "变更记录包含重复字段", + "restore.invalid_pointer": "无法定位恢复字段的父对象", + "restore.unsupported_shape": "仅支持对象中的单个标量字段;集合需要专用适配器", + "software.astrbot": "消息平台 Agent 与机器人运行环境", + "software.cc_switch": "服务商配置管理与本地路由", + "software.coding_cli": "终端编程助手", + "software.pi": "终端 Agent,LMM Provider 已有独立项目" +} diff --git a/scripts/check-i18n.py b/scripts/check-i18n.py new file mode 100644 index 0000000..8cdcc1a --- /dev/null +++ b/scripts/check-i18n.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Validate bundled UI catalogs and their Rust message references (stdlib only).""" +from __future__ import annotations + +import json +import re +import sys +from collections import Counter +from pathlib import Path +from string import Formatter + +CJK = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]") +KEY = re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+\Z") +REFERENCE = re.compile(r'\b(?:tr|trf!)\(\s*"([a-z0-9_.]+)"') +FIELD = re.compile(r"[a-zA-Z][a-zA-Z0-9_-]*\Z") + + +def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate key: {key}") + result[key] = value + return result + + +def load_catalog(path: Path) -> dict[str, str]: + value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + if not isinstance(value, dict) or not value: + raise ValueError("catalog must be a nonempty JSON object") + for key, message in value.items(): + if not KEY.fullmatch(key): + raise ValueError(f"invalid message key: {key!r}") + if not isinstance(message, str) or not message.strip(): + raise ValueError(f"{key}: message must be a nonempty string") + if any(ord(c) < 32 and c not in "\n\t" for c in message): + raise ValueError(f"{key}: disallowed control character") + return value + + +def placeholders(message: str) -> Counter[str]: + result: Counter[str] = Counter() + for _, name, spec, conversion in Formatter().parse(message): + if name is not None: + if not FIELD.fullmatch(name) or spec or conversion: + raise ValueError(f"unsupported placeholder: {name!r}") + result[name] += 1 + return result + + +def validate(root: Path) -> list[str]: + errors: list[str] = [] + catalogs: dict[str, dict[str, str]] = {} + for path in sorted((root / "locales").glob("*.json")): + try: + catalogs[path.stem] = load_catalog(path) + except (ValueError, OSError) as error: + errors.append(f"{path.name}: {error}") + if "en" not in catalogs or "zh-CN" not in catalogs: + errors.append("both en.json and zh-CN.json are required") + english = catalogs.get("en", {}) + for name, messages in catalogs.items(): + missing = sorted(english.keys() - messages.keys()) + extra = sorted(messages.keys() - english.keys()) + if missing or extra: + errors.append(f"{name}: missing keys {missing}; extra keys {extra}") + for key, message in messages.items(): + try: + fields = placeholders(message) + if key in english and fields != placeholders(english[key]): + errors.append(f"{name}/{key}: placeholder mismatch") + except ValueError as error: + errors.append(f"{name}/{key}: {error}") + if any(CJK.search(message) for message in english.values()): + errors.append("en: unexpected CJK text in the English catalog") + sources = sorted((root / "src").rglob("*.rs")) + if not sources: + errors.append("no Rust source files found") + for path in sources: + text = path.read_text(encoding="utf-8") + production = "" if path.name == "tests.rs" else re.split( + r"#\[cfg\(test\)\]\s*mod\s+\w+\s*\{", text, maxsplit=1 + )[0] + for line, content in enumerate(production.splitlines(), 1): + if CJK.search(content): + errors.append(f"{path.relative_to(root)}:{line}: move CJK UI text to locales") + references = set(REFERENCE.findall(production)) + references.update(re.findall(r'\btext\([^,\n]+,\s*"([a-z0-9_.]+)"', production)) + if path.name == "catalog.rs": + references.update(re.findall(r'"(software\.[a-z0-9_]+)"', text)) + for key in sorted(references - english.keys()): + errors.append(f"{path.relative_to(root)}: missing message {key}") + return errors + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + errors = validate(root) + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + count = len(load_catalog(root / "locales/en.json")) + print(f"i18n checks passed: {count} keys per catalog; source references and placeholders match.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_i18n_checks.py b/scripts/test_i18n_checks.py new file mode 100644 index 0000000..43dcd69 --- /dev/null +++ b/scripts/test_i18n_checks.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Regression tests for the catalog validator; no Rust toolchain is required.""" +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location("check_i18n", Path(__file__).with_name("check-i18n.py")) +assert SPEC is not None and SPEC.loader is not None +CHECK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECK) + + +class CatalogChecks(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + (self.root / "src").mkdir() + (self.root / "locales").mkdir() + (self.root / "src/main.rs").write_text('fn main() { tr("test.message"); }\n', encoding="utf-8") + self.write("en", {"test.message": "Hello {name}"}) + self.write("zh-CN", {"test.message": "你好,{name}"}) + + def write(self, language: str, value: object) -> None: + (self.root / f"locales/{language}.json").write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8") + + def test_valid_catalogs(self) -> None: + self.assertEqual(CHECK.validate(self.root), []) + + def test_duplicate_keys_are_rejected(self) -> None: + (self.root / "locales/zh-CN.json").write_text('{"test.message":"a","test.message":"b"}', encoding="utf-8") + self.assertTrue(any("duplicate key" in error for error in CHECK.validate(self.root))) + + def test_missing_and_extra_keys_are_rejected(self) -> None: + self.write("zh-CN", {"test.other": "Other"}) + self.assertTrue(any("missing keys" in error and "extra keys" in error for error in CHECK.validate(self.root))) + + def test_placeholder_mismatch_and_malformed_template_are_rejected(self) -> None: + for message in ["Hello {other}", "Hello {name", "Hello {name!r}"]: + with self.subTest(message=message): + self.write("zh-CN", {"test.message": message}) + self.assertTrue(CHECK.validate(self.root)) + + def test_empty_and_non_string_messages_are_rejected(self) -> None: + for message in ["", " ", None, 1, {"nested": "value"}]: + with self.subTest(message=message): + self.write("zh-CN", {"test.message": message}) + self.assertTrue(CHECK.validate(self.root)) + + def test_source_literals_and_missing_references_are_rejected(self) -> None: + (self.root / "src/main.rs").write_text('fn main() { tr("test.missing"); println!("中文"); }', encoding="utf-8") + errors = CHECK.validate(self.root) + self.assertTrue(any("CJK UI text" in error for error in errors)) + self.assertTrue(any("missing message" in error for error in errors)) + + def test_english_catalog_and_control_characters_are_checked(self) -> None: + for message in ["中文 {name}", "Hello\u001b {name}"]: + with self.subTest(message=message): + self.write("en", {"test.message": message}) + self.assertTrue(CHECK.validate(self.root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/catalog.rs b/src/catalog.rs index 3b5c12c..5977cbb 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -1,5 +1,6 @@ //! Explicit project identities and independently stated integration capabilities. +use crate::i18n::{self, Locale}; use serde::Serialize; /// A capability with no verified adapter must never be promoted by discovery. @@ -21,7 +22,8 @@ pub struct Software { pub name: &'static str, /// Canonical project identity, not a download mirror. pub source: &'static str, - /// Intended use. + /// Message key for intended use; serialized as a localized description. + #[serde(serialize_with = "i18n::serialize_message")] pub purpose: &'static str, /// Installation capability of this LMM release. pub installation: Capability, @@ -61,42 +63,42 @@ pub static SOFTWARE: &[Software] = &[ "astrbot", "AstrBot", "https://github.com/AstrBotDevs/AstrBot", - "消息平台 Agent 与机器人运行环境", + "software.astrbot", &["astrbot"], ), software( "cc-switch", "CC Switch", "https://github.com/farion1231/cc-switch", - "服务商配置管理与本地路由", + "software.cc_switch", &["cc-switch"], ), software( "claude-code", "Claude Code CLI", "https://github.com/anthropics/claude-code", - "终端编程助手", + "software.coding_cli", &["claude"], ), software( "codex", "OpenAI Codex CLI", "https://github.com/openai/codex", - "终端编程助手", + "software.coding_cli", &["codex"], ), software( "gemini-cli", "Gemini CLI", "https://github.com/google-gemini/gemini-cli", - "终端编程助手", + "software.coding_cli", &["gemini"], ), software( "pi", "Pi coding agent", "https://github.com/badlogic/pi-mono", - "终端 Agent,LMM Provider 已有独立项目", + "software.pi", &["pi"], ), ]; @@ -112,9 +114,14 @@ pub fn search(query: &str) -> Vec<&'static Software> { SOFTWARE .iter() .filter(|item| { - [item.id, item.name, item.purpose] + [item.id, item.name] .iter() .any(|text| text.to_lowercase().contains(&query)) + || Locale::ALL.iter().any(|&locale| { + i18n::text(locale, item.purpose) + .to_lowercase() + .contains(&query) + }) }) .collect() } diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..d194118 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,274 @@ +//! Embedded UI messages. Protocol values, identifiers, and provider data are not translated. +//! +//! The CLI chooses one immutable language before building its command tree. Libraries +//! can use `text` with an explicit locale without changing process-wide state. + +use serde::Serializer; +use std::{ + collections::BTreeMap, + env, + ffi::OsString, + sync::{LazyLock, OnceLock}, +}; + +/// Languages with complete, bundled message catalogs. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Locale { + #[default] + En, + ZhCn, +} + +impl Locale { + pub const ALL: [Self; 2] = [Self::En, Self::ZhCn]; + + pub const fn tag(self) -> &'static str { + match self { + Self::En => "en", + Self::ZhCn => "zh-CN", + } + } + + const fn index(self) -> usize { + match self { + Self::En => 0, + Self::ZhCn => 1, + } + } + + /// Normalize common BCP 47 and POSIX locale names. Unsupported locales use English. + /// Traditional Chinese is not silently labeled as a Simplified Chinese translation. + pub fn from_environment(value: &str) -> Self { + let name = value + .trim() + .split(['.', '@']) + .next() + .unwrap_or_default() + .replace('_', "-") + .to_ascii_lowercase(); + match name.as_str() { + "zh" | "zh-cn" | "zh-sg" | "zh-hans" => Self::ZhCn, + name if name.starts_with("zh-hans-") => Self::ZhCn, + _ => Self::En, + } + } +} + +type Messages = BTreeMap; + +// Keep parse failures visible to tests. A damaged/missing translation must not +// crash a read-only command or change its authorization behavior. +static CATALOGS: LazyLock<[Result; 2]> = LazyLock::new(|| { + [ + serde_json::from_str(include_str!("../locales/en.json")), + serde_json::from_str(include_str!("../locales/zh-CN.json")), + ] +}); +static ACTIVE_LOCALE: OnceLock = OnceLock::new(); + +/// CLI argument > LMM_LANG > LC_ALL > LC_MESSAGES > LANG > English. +/// Empty values are ignored. An unsupported high-priority value still overrides +/// lower-priority values, so LC_ALL=C always selects English. +fn choose_locale(explicit: Option<&str>, environment: &[Option<&str>]) -> Locale { + explicit + .into_iter() + .chain(environment.iter().copied().flatten()) + .find(|value| !value.trim().is_empty()) + .map(Locale::from_environment) + .unwrap_or_default() +} + +/// Read only the global language option before Clap generates help. Clap remains +/// responsible for validation. Never interpret positional text after `--` as flags. +fn language_argument(args: &[OsString]) -> Option { + let mut args = args.iter().skip(1); + let mut language = None; + while let Some(arg) = args.next() { + if arg == "--" { + break; + } + if arg == "--lang" { + let value = args.next()?; + if value == "--" { + break; + } + language = value.to_str().map(str::to_owned); + } else if let Some(value) = arg.to_str().and_then(|arg| arg.strip_prefix("--lang=")) { + language = Some(value.to_owned()); + } + } + language +} + +/// Initialize once, before building CLI help or starting an OAuth callback thread. +/// This reads no files, credentials, or network and does not modify the environment. +pub fn init_from_args(args: &[OsString]) { + let explicit = language_argument(args); + let values = ["LMM_LANG", "LC_ALL", "LC_MESSAGES", "LANG"].map(|key| env::var(key).ok()); + let environment = values.each_ref().map(|value| value.as_deref()); + let selected = choose_locale(explicit.as_deref(), &environment); + let _ = ACTIVE_LOCALE.set(selected); +} + +pub fn locale() -> Locale { + ACTIVE_LOCALE.get().copied().unwrap_or_default() +} + +/// Look up a message in one language, then English, then the stable message key. +/// Missing keys are also rejected by the catalog checks in CI. +pub fn text(locale: Locale, key: &str) -> &str { + CATALOGS[locale.index()] + .as_ref() + .ok() + .and_then(|messages| messages.get(key)) + .or_else(|| { + CATALOGS[Locale::En.index()] + .as_ref() + .ok() + .and_then(|messages| messages.get(key)) + }) + .map(String::as_str) + .unwrap_or(key) +} + +/// Look up a fixed message for the active CLI language. +pub fn tr(key: &'static str) -> &'static str { + text(locale(), key) +} + +/// Serialize a catalog description, not its internal message key. +pub fn serialize_message(key: &str, serializer: S) -> Result { + serializer.serialize_str(text(locale(), key)) +} + +/// Substitute named placeholders in one pass. Values are never parsed as templates. +/// Callers must still escape untrusted terminal values (for example with Debug). +fn interpolate(template: &str, arguments: &[(&str, String)]) -> String { + let mut output = String::with_capacity(template.len()); + let mut remaining = template; + while let Some((prefix, after_open)) = remaining.split_once('{') { + output.push_str(prefix); + let Some((name, after_close)) = after_open.split_once('}') else { + output.push('{'); + output.push_str(after_open); + return output; + }; + if let Some((_, value)) = arguments.iter().find(|(key, _)| *key == name) { + output.push_str(value); + } else { + output.push('{'); + output.push_str(name); + output.push('}'); + } + remaining = after_close; + } + output.push_str(remaining); + output +} + +pub fn format(key: &str, arguments: &[(&str, String)]) -> String { + interpolate(text(locale(), key), arguments) +} + +/// Format a catalog message with named, reorderable placeholders. +#[macro_export] +macro_rules! trf { + ($key:literal $(, $name:ident = $value:expr)* $(,)?) => { + $crate::i18n::format($key, &[$((stringify!($name), ($value).to_string())),*]) + }; +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn locale_names_and_unsupported_languages_are_normalized() { + for name in [ + "zh", + "zh_CN.UTF-8", + "ZH-cn", + "zh-Hans", + "zh_Hans_CN", + "zh_SG@variant", + ] { + assert_eq!(Locale::from_environment(name), Locale::ZhCn, "{name}"); + } + for name in [ + "en", + "en_US.UTF-8", + "C", + "C.UTF-8", + "POSIX", + "fr_FR", + "zh-TW", + "zh-Hant", + "", + ] { + assert_eq!(Locale::from_environment(name), Locale::En, "{name}"); + } + } + + #[test] + fn locale_precedence_and_empty_values_are_explicit() { + assert_eq!(choose_locale(Some("en"), &[Some("zh-CN")]), Locale::En); + assert_eq!(choose_locale(None, &[Some("zh-CN"), Some("C")]), Locale::ZhCn); + assert_eq!( + choose_locale(None, &[None, Some("C"), Some("zh-CN")]), + Locale::En + ); + assert_eq!( + choose_locale(None, &[Some(""), Some(" "), Some("zh-CN")]), + Locale::ZhCn + ); + assert_eq!(choose_locale(None, &[Some("fr"), Some("zh-CN")]), Locale::En); + assert_eq!(choose_locale(None, &[]), Locale::En); + } + + #[test] + fn global_language_flag_is_found_before_help_but_not_after_separator() { + for words in [ + vec!["lmm", "--lang", "zh-CN", "--help"], + vec!["lmm", "status", "--help", "--lang=zh-CN"], + ] { + let args: Vec<_> = words.into_iter().map(OsString::from).collect(); + assert_eq!(language_argument(&args).as_deref(), Some("zh-CN")); + } + let args: Vec<_> = ["lmm", "presets", "--", "--lang=zh-CN"] + .into_iter() + .map(OsString::from) + .collect(); + assert_eq!(language_argument(&args), None); + } + + #[test] + fn bundled_catalogs_parse_and_have_matching_nonempty_keys() { + let english = CATALOGS[0].as_ref().unwrap(); + assert!(!english.is_empty()); + for catalog in CATALOGS.iter() { + let catalog = catalog.as_ref().unwrap(); + assert_eq!( + english.keys().collect::>(), + catalog.keys().collect::>() + ); + assert!(catalog.values().all(|value| !value.trim().is_empty())); + } + } + + #[test] + fn unknown_keys_are_safe_and_requested_locale_does_not_change_global_state() { + assert_eq!(text(Locale::ZhCn, "missing.key"), "missing.key"); + assert_ne!(text(Locale::En, "cli.about"), text(Locale::ZhCn, "cli.about")); + assert_eq!(locale(), Locale::En); + } + + #[test] + fn interpolation_allows_reordering_without_reinterpreting_values() { + let values = [("first", "{second}".to_owned()), ("second", "B".to_owned())]; + assert_eq!(interpolate("{second}: {first}", &values), "B: {second}"); + assert_eq!(interpolate("{missing}", &values), "{missing}"); + assert_eq!(interpolate("literal {", &values), "literal {"); + assert_eq!(interpolate("plain", &[]), "plain"); + } +} diff --git a/src/lib.rs b/src/lib.rs index a0fed60..070df8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod catalog; pub mod discovery; pub mod environment; +pub mod i18n; pub mod oauth; pub mod plan; pub mod provider; diff --git a/src/main.rs b/src/main.rs index cd927a2..d6e8b4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,12 @@ -use clap::{Args, Parser, Subcommand}; +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use lmm::{ catalog, discovery::{self, ProbeContext, TargetStatus}, environment::{self, Environment}, + i18n::{self, tr}, oauth, plan::{Intent, Preview}, - provider, + provider, trf, }; use serde::Serialize; use std::{ @@ -17,16 +18,20 @@ use std::{ }; #[derive(Parser)] -#[command( - name = "lmm", - version, - about = "LMM 安装与接入工具(开发预览:环境检查与操作规划)" -)] +#[command(name = "lmm", version, about = tr("cli.about"))] struct Cli { - /// 输出结构化 JSON;不交互、不输出配置内容 + #[arg( + long = "lang", + global = true, + value_name = "LANG", + value_parser = ["en", "zh-CN"], + help = tr("arg.lang") + )] + _lang: Option, + #[arg(help = tr("arg.json"))] #[arg(long, global = true)] json: bool, - /// 无人值守:缺少目标或授权时直接返回 + #[arg(help = tr("arg.non_interactive"))] #[arg(long, global = true)] non_interactive: bool, #[command(subcommand)] @@ -35,41 +40,41 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// 搜索内置服务商预设快照;不读取配置、凭据或网络 + #[command(about = tr("command.presets"))] Presets { query: Option }, - /// 搜索软件目录;目录条目不代表已验证兼容 + #[command(about = tr("command.catalog"))] Catalog { query: Option }, - /// 规划完整接入;当前版本在未具备适配器时明确阻止写入 + #[command(about = tr("command.setup"))] Setup(SetupArgs), - /// 安装软件;不要求 LMM 登录 + #[command(about = tr("command.install"))] Install(OperationArgs), - /// 通过浏览器登录,凭据保存在系统凭据库 + #[command(about = tr("command.login"))] Login(LoginArgs), - /// 退出 CLI;不撤销独立应用授权 + #[command(about = tr("command.logout"))] Logout(AuthArgs), - /// 读取当前授权模型与计费信息,不发起模型调用或更改选择 + #[command(about = tr("command.models"))] Models(AuthArgs), - /// 只读检查环境、安装线索与各层验证状态 + #[command(about = tr("command.status"))] Status(TargetArgs), - /// 同步 LMM 信息,保留当前服务商与模型 + #[command(about = tr("command.sync"))] Sync(OperationArgs), - /// 更新明确纳入管理的软件 + #[command(about = tr("command.update"))] Update(OperationArgs), - /// 只读诊断;--fix 仍受适配器能力与确认范围限制 + #[command(about = tr("command.doctor"))] Doctor(DoctorArgs), - /// 按字段恢复 LMM 变更,保留后续修改 + #[command(about = tr("command.restore"))] Restore(OperationArgs), - /// 解除指定软件接入并撤销对应授权 + #[command(about = tr("command.disconnect"))] Disconnect(RequiredTarget), - /// 卸载已确认纳入管理的软件,默认保留数据 + #[command(about = tr("command.uninstall"))] Uninstall(RequiredTarget), - /// 按当前配置启动指定软件 + #[command(about = tr("command.run"))] Run(RequiredTarget), } #[derive(Args)] struct AuthArgs { - /// 信任的 LMM HTTPS origin(自托管时显式指定) + #[arg(help = tr("arg.issuer"))] #[arg(long, default_value = oauth::DEFAULT_ISSUER)] issuer: String, } @@ -78,19 +83,19 @@ struct AuthArgs { struct LoginArgs { #[command(flatten)] auth: AuthArgs, - /// 只显示登录链接;仍需要浏览器能访问当前机器的回环端口 + #[arg(help = tr("arg.no_browser"))] #[arg(long)] no_browser: bool, - /// 浏览器回调等待上限(秒) + #[arg(help = tr("arg.timeout"))] #[arg(long, default_value_t = 180, value_parser = clap::value_parser!(u64).range(10..=600))] timeout: u64, } #[derive(Args)] struct TargetArgs { - /// 精确的软件目录 ID,例如 astrbot 或 cc-switch + #[arg(help = tr("arg.software"))] software: Option, - /// 明确的本地实例根目录;不自动扫描远程或 Docker 实例 + #[arg(help = tr("arg.instance"))] #[arg(long, requires = "software")] instance: Option, } @@ -99,13 +104,13 @@ struct TargetArgs { struct OperationArgs { #[command(flatten)] target: TargetArgs, - /// 仅包含当前环境中已安装且受支持的目标;更新还要求管理授权 + #[arg(help = tr("arg.all"))] #[arg(long, conflicts_with_all = ["software", "instance"])] all: bool, - /// 只生成计划,不执行修改 + #[arg(help = tr("arg.dry_run"))] #[arg(long)] dry_run: bool, - /// 确认已展示范围;不代表允许切换账号、服务商或付费测试 + #[arg(help = tr("arg.yes"))] #[arg(long)] yes: bool, } @@ -114,10 +119,10 @@ struct OperationArgs { struct SetupArgs { #[command(flatten)] operation: OperationArgs, - /// 仅添加 LMM,保留当前选择 + #[arg(help = tr("arg.add_only"))] #[arg(long, conflicts_with = "activate")] add_only: bool, - /// 明确请求添加并切换到 LMM(不表示已执行) + #[arg(help = tr("arg.activate"))] #[arg(long)] activate: bool, } @@ -126,18 +131,19 @@ struct SetupArgs { struct DoctorArgs { #[command(flatten)] target: TargetArgs, - /// 请求修复已获允许的 LMM 配置;不会绕过能力检查 + #[arg(help = tr("arg.fix"))] #[arg(long)] fix: bool, - /// 生成可主动分享的摘要,去除路径和配置内容 + #[arg(help = tr("arg.report"))] #[arg(long)] report: bool, } #[derive(Args)] struct RequiredTarget { + #[arg(help = tr("arg.software"))] software: String, - #[arg(long)] + #[arg(long, help = tr("arg.instance"))] instance: Option, } @@ -199,14 +205,14 @@ fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result { - let software = catalog::find(id) - .ok_or_else(|| "未知软件 ID;运行 lmm catalog 查看精确名称".to_owned())?; + let software = + catalog::find(id).ok_or_else(|| tr("error.unknown_software").to_owned())?; if target.instance.is_some() && !matches!(id, "astrbot" | "cc-switch") { - return Err("此软件尚未实现实例选择,不能忽略 --instance 后继续操作".into()); + return Err(tr("error.unsupported_instance").into()); } Ok(vec![discovery::discover( software, @@ -223,18 +229,17 @@ fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result Result { let mut output = io::stderr().lock(); - writeln!(output, "选择要规划接入的软件(当前均未完成集成验证):") - .map_err(|_| "无法显示选择菜单")?; + writeln!(output, "{}", tr("menu.title")).map_err(|_| tr("error.menu_output"))?; for (i, software) in catalog::SOFTWARE.iter().enumerate() { writeln!(output, " {}. {} ({})", i + 1, software.name, software.id) - .map_err(|_| "无法显示选择菜单")?; + .map_err(|_| tr("error.menu_output"))?; } - write!(output, "输入序号,或直接回车取消:").map_err(|_| "无法显示选择菜单")?; - output.flush().map_err(|_| "无法显示选择菜单")?; + write!(output, "{}", tr("menu.prompt")).map_err(|_| tr("error.menu_output"))?; + output.flush().map_err(|_| tr("error.menu_output"))?; let mut input = String::new(); io::stdin() .read_line(&mut input) - .map_err(|_| "无法读取选择")?; + .map_err(|_| tr("error.menu_input"))?; let index = input .trim() .parse::() @@ -243,7 +248,7 @@ fn select_software() -> Result { index .and_then(|index| catalog::SOFTWARE.get(index)) .map(|software| software.id.to_owned()) - .ok_or_else(|| "未选择目标;没有执行任何修改".to_owned()) + .ok_or_else(|| tr("error.no_selection").to_owned()) } fn operation_report( @@ -256,7 +261,7 @@ fn operation_report( let selected; let target = if args.target.software.is_none() && !args.all { if !interactive { - return Err("无人值守模式需要明确软件或 --all;没有执行任何修改".into()); + return Err(tr("error.target_required").into()); } selected = TargetArgs { software: Some(select_software()?), @@ -278,38 +283,40 @@ fn operation_report( let mut preview = Preview::blocked(target.software, intent); if name == "install" { preview.account = "not_required"; - preview.blockers = vec!["缺少经过验证的软件版本、下载来源校验及安装适配器"]; + preview.blockers = vec![tr("blocker.install")]; } else if name == "restore" { preview.account = "not_required"; - preview.blockers = vec!["尚未接入目标的变更日志与原生事务适配器,不能直接覆盖备份"]; + preview.blockers = vec![tr("blocker.restore")]; } report.plans.push(preview); } if args.all { - report.notes.push( - "当前版本没有经过验证的自动接入适配器,--all 没有可执行目标;未安装或修改任何软件", - ); + report.notes.push(tr("note.all")); } if args.dry_run { - report - .notes - .push("仅规划;阻塞项仍需解决,不能作为接入完成的证据"); + report.notes.push(tr("note.dry_run")); } if args.yes { - report.notes.push("--yes 不会跳过缺失能力、授权或费用限制"); + report.notes.push(tr("note.yes")); } if name == "install" { - report - .notes - .push("安装本身不需要 LMM 登录;当前缺少经过验证的安装适配器"); + report.notes.push(tr("note.install")); } Ok(report) } fn write_json(value: &impl Serialize) -> Result<(), String> { let mut output = io::stdout().lock(); - serde_json::to_writer_pretty(&mut output, value).map_err(|_| "无法输出 JSON".to_owned())?; - writeln!(output).map_err(|_| "无法输出 JSON".to_owned()) + serde_json::to_writer_pretty(&mut output, value).map_err(|_| tr("error.json_output").to_owned())?; + writeln!(output).map_err(|_| tr("error.json_output").to_owned()) +} + +fn observation_label(observation: discovery::Observation) -> &'static str { + match observation { + discovery::Observation::Present => tr("observation.present"), + discovery::Observation::NotFound => tr("observation.not_found"), + discovery::Observation::Unknown => tr("observation.unknown"), + } } fn emit(report: &Report, json: bool) -> Result<(), String> { @@ -322,48 +329,48 @@ fn emit(report: &Report, json: bool) -> Result<(), String> { "LMM · {} · {} / {}", report.command, report.environment.os, report.environment.arch ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| tr("error.output"))?; writeln!( output, - "范围:当前用户、当前运行环境;检查时间:{}(Unix 秒)", - report.checked_at + "{}", + trf!("output.scope", timestamp = report.checked_at) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| tr("error.output"))?; for target in &report.targets { writeln!( output, - "\n{}:安装状态无法确认;安装管理者、配置管理者均未知", - target.software + "{}", + trf!("output.target", software = target.software) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| tr("error.output"))?; for evidence in &target.evidence { // Debug formatting escapes control characters in user-controlled paths. writeln!( output, - " {:?}: {:?}(仅路径线索)", - evidence.path, evidence.observation + "{}", + trf!( + "output.evidence", + path = format!("{:?}", evidence.path), + observation = observation_label(evidence.observation) + ) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| tr("error.output"))?; } - writeln!( - output, - " LMM 添加/启用:未知;授权、连通性、目标调用、流式及工具调用:未检查" - ) - .map_err(|_| "无法输出结果")?; + writeln!(output, "{}", tr("output.verification")).map_err(|_| tr("error.output"))?; } for plan in &report.plans { writeln!( output, - "\n{} 计划已阻止:无配置修改、无付费调用、无重启", - plan.software + "{}", + trf!("output.blocked", software = plan.software) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| tr("error.output"))?; for blocker in &plan.blockers { - writeln!(output, " - {blocker}").map_err(|_| "无法输出结果")?; + writeln!(output, " - {blocker}").map_err(|_| tr("error.output"))?; } } for note in &report.notes { - writeln!(output, "\n{note}").map_err(|_| "无法输出结果")?; + writeln!(output, "\n{note}").map_err(|_| tr("error.output"))?; } Ok(()) } @@ -375,7 +382,7 @@ fn execute(cli: &Cli) -> Result { Command::Login(_) | Command::Logout(_) | Command::Models(_) ) { - return Err("当前系统凭据库可能需要解锁交互;OAuth 命令尚不支持 --non-interactive,未访问凭据或网络".into()); + return Err(tr("error.auth_non_interactive").into()); } let context = context(); let interactive = !cli.json @@ -385,17 +392,17 @@ fn execute(cli: &Cli) -> Result { let report = match &cli.command { Command::Presets { query } => { let snapshot = provider::search(query.as_deref().unwrap_or_default()) - .map_err(|_| "内置服务商预设无法读取".to_owned())?; + .map_err(|_| tr("error.presets_read").to_owned())?; if cli.json { write_json(&snapshot)?; } else { let mut output = io::stdout().lock(); writeln!( output, - "内置服务商预设(Magpie 快照 {});配置与连通性未检查,未添加服务商。", - provider::UPSTREAM_REVISION + "{}", + trf!("presets.title", revision = provider::UPSTREAM_REVISION) ) - .map_err(|_| "无法输出服务商预设")?; + .map_err(|_| tr("error.presets_output"))?; for preset in &snapshot.presets { writeln!( output, @@ -404,7 +411,7 @@ fn execute(cli: &Cli) -> Result { preset.name, preset.kind.as_str() ) - .map_err(|_| "无法输出服务商预设")?; + .map_err(|_| tr("error.presets_output"))?; } } return Ok(0); @@ -416,7 +423,18 @@ fn execute(cli: &Cli) -> Result { } else { let mut output = io::stdout().lock(); for software in entries { - writeln!(output, "{} — {}\n {}\n 来源:{}\n 安装/接入:尚未支持;维护:仅本地线索检查;兼容版本/平台/场景:未验证\n", software.id, software.name, software.purpose, software.source).map_err(|_| "无法输出目录")?; + writeln!( + output, + "{}", + trf!( + "catalog.entry", + id = software.id, + name = software.name, + purpose = tr(software.purpose), + source = software.source + ) + ) + .map_err(|_| tr("error.catalog_output"))?; } } return Ok(0); @@ -425,12 +443,10 @@ fn execute(cli: &Cli) -> Result { Command::Doctor(args) => { let mut report = Report::new("doctor", inspect(&args.target, &context)?); report.outcome = "incomplete"; - report.notes.push("诊断未完成:仅检查本地路径线索;请核对目标实例、版本及配置管理关系。没有执行网络或付费测试"); + report.notes.push(tr("note.doctor")); if args.fix { report.outcome = "blocked"; - report - .notes - .push("当前没有经过验证的修复适配器;未修改配置"); + report.notes.push(tr("note.fix")); } if args.report { report.redact(); @@ -464,20 +480,17 @@ fn execute(cli: &Cli) -> Result { } Command::Login(args) => { if !interactive { - return Err("登录需要浏览器授权;请在交互终端运行 lmm login。无人值守模式不会等待或代替用户同意".into()); + return Err(tr("error.login_interactive").into()); } let result = oauth::login( &args.auth.issuer, std::time::Duration::from_secs(args.timeout), |url| { let mut output = io::stderr().lock(); - writeln!( - output, - "请在浏览器确认 LMM CLI 授权(仅目录和余额读取,不迁移任何应用):\n{url}" - ) + writeln!(output, "{}", trf!("login.authorize", url = url)) .map_err(|_| oauth::AuthError::Output)?; if !args.no_browser && open::that_detached(url).is_err() { - writeln!(output, "浏览器未能自动打开,请在本机浏览器打开上述链接。") + writeln!(output, "{}", tr("login.browser_failed")) .map_err(|_| oauth::AuthError::Output)?; } Ok(()) @@ -488,8 +501,8 @@ fn execute(cli: &Cli) -> Result { if cli.json { write_json(&status)?; } else { - writeln!(io::stdout().lock(), "LMM CLI 已登录,凭据已保存到系统凭据库。尚未授予应用调用权限;可运行 lmm models。") - .map_err(|_| "无法输出登录结果")?; + writeln!(io::stdout().lock(), "{}", tr("login.success")) + .map_err(|_| tr("error.login_output"))?; } Ok(0) } @@ -504,11 +517,8 @@ fn execute(cli: &Cli) -> Result { &serde_json::json!({"outcome": outcome, "applications_changed": false, "other_application_grants": "not_checked"}), )?; } else { - writeln!( - io::stdout().lock(), - "CLI 已退出;其他应用授权未撤销,当前版本尚不能列出这些授权。" - ) - .map_err(|_| "无法输出退出结果")?; + writeln!(io::stdout().lock(), "{}", tr("logout.success")) + .map_err(|_| tr("error.logout_output"))?; } Ok(0) } @@ -522,13 +532,26 @@ fn execute(cli: &Cli) -> Result { write_json(&catalog)?; } else { let mut output = io::stdout().lock(); - writeln!( - output, - "当前授权模型(不代表目标软件兼容;价格可能随使用变化):" - ) - .map_err(|_| "无法输出模型")?; + writeln!(output, "{}", tr("models.title")) + .map_err(|_| tr("error.models_output"))?; for model in &catalog.models { - writeln!(output, "{:?} · {:?} · {:?}\n 价格单位:{:?} / {:?};输入:{:?},输出:{:?},每次请求:{:?};依据:{:?}\n null/None 表示未知;未调用或切换模型。", model.id, model.upstream_model, model.group, model.pricing.currency, model.pricing.unit, model.pricing.input, model.pricing.output, model.pricing.request, model.pricing.price_basis).map_err(|_| "无法输出模型")?; + writeln!( + output, + "{}", + trf!( + "models.entry", + id = format!("{:?}", model.id), + upstream = format!("{:?}", model.upstream_model), + group = format!("{:?}", model.group), + currency = format!("{:?}", model.pricing.currency), + unit = format!("{:?}", model.pricing.unit), + input = format!("{:?}", model.pricing.input), + output = format!("{:?}", model.pricing.output), + request = format!("{:?}", model.pricing.request), + basis = format!("{:?}", model.pricing.price_basis) + ) + ) + .map_err(|_| tr("error.models_output"))?; } } Ok(0) @@ -548,9 +571,7 @@ fn execute(cli: &Cli) -> Result { }; let mut report = Report::new(name, inspect(&target, &context)?); report.outcome = "blocked"; - report - .notes - .push("未实现该目标的安全执行适配器;未启动或卸载软件,未修改配置或撤销授权"); + report.notes.push(tr("note.execution")); report } }; @@ -565,7 +586,12 @@ fn execute(cli: &Cli) -> Result { fn auth_failure(error: oauth::AuthError, json: bool) -> Result { if json { write_json( - &serde_json::json!({"schema_version":1,"outcome":"blocked","error":error.to_string()}), + &serde_json::json!({ + "schema_version": 1, + "outcome": "blocked", + "error": error.to_string(), + "error_code": error.code(), + }), )?; } else { eprintln!("lmm: {error}"); @@ -573,8 +599,39 @@ fn auth_failure(error: oauth::AuthError, json: bool) -> Result { Ok(3) } +fn localize_help(command: clap::Command) -> clap::Command { + command + .help_template(tr("help.template")) + .subcommand_help_heading(tr("help.commands")) + .mut_args(|arg| { + let arg = match arg.get_id().as_str() { + "help" => arg.help(tr("help.flag")), + "version" => arg.help(tr("help.version")), + _ => arg, + }; + let heading = if arg.is_positional() { + tr("help.arguments") + } else { + tr("help.options") + }; + arg.help_heading(heading) + }) + .mut_subcommands(|command| { + if command.get_name() == "help" { + command.about(tr("help.subcommand")) + } else { + localize_help(command) + } + }) +} + fn main() -> ExitCode { - let cli = Cli::parse(); + let args: Vec<_> = env::args_os().collect(); + i18n::init_from_args(&args); + let mut command = Cli::command(); + command.build(); + let matches = localize_help(command).get_matches_from(args); + let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); match execute(&cli) { Ok(code) => ExitCode::from(code), Err(error) => { diff --git a/src/oauth.rs b/src/oauth.rs index 7e2dac9..1838616 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -7,6 +7,7 @@ mod storage; #[cfg(test)] mod tests; +use crate::i18n::tr; use protocol::{Credential, TokenResponse}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; @@ -26,38 +27,58 @@ const SCOPE: &str = "catalog:read balance:read"; /// Errors contain fixed messages only: never HTTP bodies, URLs or credentials. #[derive(Debug, Error, PartialEq, Eq)] pub enum AuthError { - #[error("issuer 必须是规范的 HTTPS 域名 origin,不含端口、路径、查询或凭据")] + #[error("{}", tr("oauth.invalid_issuer"))] InvalidIssuer, - #[error("LMM 网络请求失败;请检查网络和服务端是否已启用 lmm 客户端")] + #[error("{}", tr("oauth.network"))] Network, - #[error("LMM OAuth 响应不符合约定,已停止授权")] + #[error("{}", tr("oauth.invalid_response"))] InvalidResponse, - #[error("授权已取消或被拒绝")] + #[error("{}", tr("oauth.denied"))] Denied, - #[error("登录等待超时;没有继续发起请求")] + #[error("{}", tr("oauth.timeout"))] Timeout, - #[error("无法绑定本机 OAuth 回调端口")] + #[error("{}", tr("oauth.callback"))] Callback, - #[error( - "无法访问系统凭据库;请解锁 Keychain、Credential Manager 或 Secret Service,不会回退到明文文件" - )] + #[error("{}", tr("oauth.credential_store"))] CredentialStore, - #[error("尚未登录;请先运行 lmm login")] + #[error("{}", tr("oauth.not_logged_in"))] NotLoggedIn, - #[error("已有 CLI 登录;切换账号请先 lmm logout,不会迁移应用授权")] + #[error("{}", tr("oauth.already_logged_in"))] AlreadyLoggedIn, - #[error("上次刷新未可靠完成;不会重放旧刷新令牌,请先 logout 再 login")] + #[error("{}", tr("oauth.refresh_blocked"))] RefreshBlocked, - #[error("另一项 LMM 授权操作正在运行,或无法建立当前用户的安全锁")] + #[error("{}", tr("oauth.busy"))] Busy, - #[error("服务端返回未授权;请重新登录,或检查 lmm 客户端是否已部署")] + #[error("{}", tr("oauth.unauthorized"))] Unauthorized, - #[error("新授权未能保存,且无法确认已撤销;请在 LMM 授权页检查 LMM CLI 授权")] + #[error("{}", tr("oauth.unstored_grant"))] UnstoredGrant, - #[error("无法输出登录入口;登录已停止")] + #[error("{}", tr("oauth.output"))] Output, } +impl AuthError { + /// Stable machine-readable code, independent of the display language. + pub fn code(&self) -> &'static str { + match self { + Self::InvalidIssuer => "oauth.invalid_issuer", + Self::Network => "oauth.network", + Self::InvalidResponse => "oauth.invalid_response", + Self::Denied => "oauth.denied", + Self::Timeout => "oauth.timeout", + Self::Callback => "oauth.callback", + Self::CredentialStore => "oauth.credential_store", + Self::NotLoggedIn => "oauth.not_logged_in", + Self::AlreadyLoggedIn => "oauth.already_logged_in", + Self::RefreshBlocked => "oauth.refresh_blocked", + Self::Busy => "oauth.busy", + Self::Unauthorized => "oauth.unauthorized", + Self::UnstoredGrant => "oauth.unstored_grant", + Self::Output => "oauth.output", + } + } +} + /// Login information deliberately excludes tokens, account secrets and scopes with group IDs. #[derive(Debug, Serialize)] pub struct LoginStatus { diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index 0e671f8..1ac42fe 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -1,4 +1,5 @@ use super::AuthError; +use crate::i18n::{self, Locale}; use std::{ collections::BTreeMap, io::{Read, Write}, @@ -47,22 +48,9 @@ impl Callback { break; } let result = read_request(&mut stream, &host, issuer, state, remaining); - let body = if result.is_ok() { - "Authorization received. Return to LMM CLI to check completion." - } else { - "Invalid or denied authorization. Return to LMM CLI." - }; - let status = if result.is_ok() { - "200 OK" - } else { - "400 Bad Request" - }; + let response = callback_response(result.is_ok(), i18n::locale()); let _ = stream.set_write_timeout(Some(Duration::from_millis(200))); - let _ = write!( - stream, - "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'none'; frame-ancestors 'none'\r\nConnection: close\r\n\r\n{body}", - body.len() - ); + let _ = stream.write_all(response.as_bytes()); match result { Ok(code) => return Ok(code), Err(AuthError::Denied) => return Err(AuthError::Denied), @@ -80,6 +68,21 @@ impl Callback { } } +/// Fixed plain-text output; neither authorization codes nor request text are echoed. +fn callback_response(accepted: bool, locale: Locale) -> String { + let body = if accepted { + i18n::text(locale, "callback.received") + } else { + i18n::text(locale, "callback.invalid") + }; + let status = if accepted { "200 OK" } else { "400 Bad Request" }; + format!( + "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nContent-Language: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'none'; frame-ancestors 'none'\r\nConnection: close\r\n\r\n{body}", + body.len(), + locale.tag() + ) +} + fn read_request( stream: &mut TcpStream, host: &str, @@ -205,6 +208,26 @@ mod tests { format!("/oauth/lmm/callback?{}", query.finish()) } + #[test] + fn callback_translation_uses_utf8_byte_lengths_and_fixed_language_headers() { + for locale in Locale::ALL { + for accepted in [true, false] { + let response = callback_response(accepted, locale); + let (headers, body) = response.split_once("\r\n\r\n").unwrap(); + let length: usize = headers.lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .unwrap().parse().unwrap(); + assert_eq!(length, body.len()); + assert!(headers.contains(&format!("Content-Language: {}", locale.tag()))); + assert!(headers.contains("Content-Type: text/plain; charset=utf-8")); + assert!(headers.contains("Cache-Control: no-store")); + assert!(headers.contains("Content-Security-Policy: default-src 'none'")); + assert!(body.contains("LMM CLI")); + assert!(!body.contains("callback.")); + } + } + } + #[test] fn callbacks_reject_mismatched_state_issuer_duplicates_and_malformed_encoding() { let good = query(&[("code", "lmm_oa_abc")]); diff --git a/src/plan.rs b/src/plan.rs index 76e4cce..9e70c3f 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,5 +1,6 @@ //! Policy decisions shared by future adapters; discovery never authorizes a write. +use crate::i18n::tr; use serde::Serialize; use thiserror::Error; @@ -43,13 +44,13 @@ pub enum Activation { #[derive(Debug, Error, PartialEq, Eq)] pub enum PlanError { /// Unknown state must not be silently replaced with defaults. - #[error("无法确认当前服务商;需要先读取选定实例的实际配置")] + #[error("{}", tr("plan.unknown_selection"))] UnknownSelection, /// A revoked application must not be silently granted permission again. - #[error("应用授权已撤销;需要明确确认重新授权")] + #[error("{}", tr("plan.revoked_grant"))] RevokedGrant, /// Cost checks must use a server-enforced reservation. - #[error("付费验证需要明确同意、已知报价和服务端可执行的消费上限")] + #[error("{}", tr("plan.paid_verification"))] PaidVerificationNotAuthorized, } @@ -130,9 +131,9 @@ impl Preview { paid_test: false, restart: false, blockers: vec![ - "此版本尚无经过验证的目标软件写入适配器", - "CLI 登录只允许读取目录和余额;目标应用独立授权尚未接入", - "未验证软件版本、配置管理关系及实际调用路径", + tr("blocker.adapter"), + tr("blocker.application_grant"), + tr("blocker.compatibility"), ], } } diff --git a/src/restore.rs b/src/restore.rs index 09065e6..827531d 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -3,6 +3,7 @@ //! Arrays and secrets require adapter-specific ownership; broad object replacement //! is deliberately rejected. A restore never renews any authorization. +use crate::i18n::tr; use serde_json::Value; use thiserror::Error; @@ -21,16 +22,16 @@ pub struct FieldChange { #[derive(Debug, Error, PartialEq, Eq)] pub enum RestoreError { /// A newer user or manager edit takes precedence over the backup. - #[error("字段在 LMM 修改后再次变化,恢复已暂停")] + #[error("{}", tr("restore.conflict"))] Conflict, /// Object/array replacement would have an overly broad scope. - #[error("仅支持对象中的单个标量字段;集合需要专用适配器")] + #[error("{}", tr("restore.unsupported_shape"))] UnsupportedShape, /// Invalid JSON pointer or missing parent. - #[error("无法定位恢复字段的父对象")] + #[error("{}", tr("restore.invalid_pointer"))] InvalidPointer, /// A malformed or overlapping journal must not reorder writes. - #[error("变更记录包含重复字段")] + #[error("{}", tr("restore.duplicate_field"))] DuplicateField, } diff --git a/tests/i18n.rs b/tests/i18n.rs new file mode 100644 index 0000000..d15e729 --- /dev/null +++ b/tests/i18n.rs @@ -0,0 +1,196 @@ +#![allow(clippy::unwrap_used)] + +use lmm::{ + catalog, + i18n::{self, Locale}, +}; +use serde_json::Value; +use std::process::{Command, Output, Stdio}; + +fn run(args: &[&str], environment: &[(&str, &str)]) -> Output { + let home = tempfile::tempdir().unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_lmm")); + command + .args(args) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("PATH", "") + .env("NO_COLOR", "1") + .stdin(Stdio::null()); + for key in ["LMM_LANG", "LC_ALL", "LC_MESSAGES", "LANG"] { + command.env_remove(key); + } + command.envs(environment.iter().copied()); + command.output().unwrap() +} + +fn stdout(output: &Output) -> &str { + std::str::from_utf8(&output.stdout).unwrap() +} + +#[test] +fn help_uses_explicit_language_in_any_global_flag_position() { + for args in [ + vec!["--lang", "zh-CN", "--help"], + vec!["--help", "--lang=zh-CN"], + vec!["status", "--help", "--lang", "zh-CN"], + vec!["--lang=zh-CN", "help", "status"], + ] { + let output = run(&args, &[("LMM_LANG", "en")]); + assert!(output.status.success(), "{:?}", output.stderr); + assert!( + stdout(&output).contains("用法"), + "{args:?}: {}", + stdout(&output) + ); + assert!(stdout(&output).contains("选项")); + assert!(!stdout(&output).contains("cli.about")); + } + let output = run(&["--lang", "en", "--help"], &[("LANG", "zh_CN.UTF-8")]); + assert!(stdout(&output).contains("Usage:")); + assert!(!stdout(&output).contains("用法")); +} + +#[test] +fn environment_precedence_normalization_and_fallback_work_in_real_processes() { + for (environment, chinese) in [ + (vec![], false), + (vec![("LANG", "zh_CN.UTF-8")], true), + (vec![("LC_ALL", "C"), ("LANG", "zh_CN.UTF-8")], false), + (vec![("LC_MESSAGES", "zh-Hans"), ("LANG", "en_US.UTF-8")], true), + (vec![("LMM_LANG", "en"), ("LC_ALL", "zh_CN.UTF-8")], false), + (vec![("LMM_LANG", ""), ("LANG", "zh_CN.UTF-8")], true), + (vec![("LMM_LANG", "unknown"), ("LANG", "zh_CN.UTF-8")], false), + (vec![("LANG", "zh-TW")], false), + ] { + let output = run(&["--help"], &environment); + assert!(output.status.success()); + assert_eq!(stdout(&output).contains("用法"), chinese, "{environment:?}"); + } +} + +#[test] +fn translated_prose_does_not_change_json_keys_statuses_or_exit_codes() { + let mut reports = Vec::new(); + for language in ["en", "zh-CN"] { + let output = run( + &["setup", "astrbot", "--dry-run", "--json", "--lang", language], + &[], + ); + assert_eq!(output.status.code(), Some(3)); + let mut report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["outcome"], "blocked"); + assert_eq!(report["plans"][0]["activation"], "preserve"); + assert_eq!(report["targets"][0]["installation"], "unknown"); + let blockers = report["plans"][0]["blockers"].as_array().unwrap(); + assert!( + blockers + .iter() + .all(|message| !message.as_str().unwrap().starts_with("blocker.")) + ); + report.as_object_mut().unwrap().remove("checked_at"); + report.as_object_mut().unwrap().remove("notes"); + report["plans"][0].as_object_mut().unwrap().remove("blockers"); + reports.push(report); + } + assert_eq!(reports[0], reports[1]); +} + +#[test] +fn oauth_errors_are_localized_without_changing_the_machine_code() { + for language in ["en", "zh-CN"] { + let output = run( + &[ + "models", + "--issuer", + "http://127.0.0.1", + "--json", + "--lang", + language, + ], + &[], + ); + assert_eq!(output.status.code(), Some(3)); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error_code"], "oauth.invalid_issuer"); + assert_eq!(error["outcome"], "blocked"); + assert_eq!( + error["error"], + i18n::text(Locale::from_environment(language), "oauth.invalid_issuer") + ); + } +} + +#[test] +fn catalog_search_accepts_both_languages_and_serializes_descriptions() { + let english = catalog::search("Terminal coding assistant"); + let chinese = catalog::search("终端编程助手"); + assert_eq!( + english.iter().map(|item| item.id).collect::>(), + chinese.iter().map(|item| item.id).collect::>() + ); + assert_eq!(english.len(), 3); + for language in ["en", "zh-CN"] { + let output = run(&["catalog", "astrbot", "--json", "--lang", language], &[]); + assert!(output.status.success()); + let entries: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + entries[0]["purpose"], + i18n::text(Locale::from_environment(language), "software.astrbot") + ); + assert_eq!(entries[0]["id"], "astrbot"); + assert_eq!(entries[0]["installation"], "unavailable"); + } +} + +#[test] +fn provider_snapshot_is_language_independent() { + let english = run(&["presets", "--json", "--lang", "en"], &[]); + let chinese = run(&["presets", "--json", "--lang", "zh-CN"], &[]); + assert!(english.status.success()); + assert!(chinese.status.success()); + assert_eq!(english.stdout, chinese.stdout); +} + +#[test] +fn invalid_language_and_separator_do_not_execute_operations() { + assert_eq!( + run(&["--lang", "unknown", "setup", "astrbot"], &[]) + .status + .code(), + Some(2) + ); + assert_eq!(run(&["--lang"], &[]).status.code(), Some(2)); + let output = run(&["catalog", "--json", "--", "--lang=zh-CN"], &[]); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!([]) + ); +} + +#[cfg(unix)] +#[test] +fn translated_output_keeps_untrusted_paths_escaped() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("line\n\u{1b}[31m{software}"); + std::fs::create_dir(&path).unwrap(); + for language in ["en", "zh-CN"] { + let output = run( + &[ + "status", + "astrbot", + "--instance", + path.to_str().unwrap(), + "--lang", + language, + ], + &[], + ); + assert!(output.status.success()); + let text = stdout(&output); + assert!(!text.contains('\u{1b}')); + assert!(text.contains("\\n")); + assert!(text.contains("{software}")); + } +} From 1a33a6a91dbfcd9471787dfb9e8c93d3ca9c5e2e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 12 Oct 2026 09:55:18 +0800 Subject: [PATCH 2/2] style: apply rustfmt to i18n changes --- src/i18n.rs | 15 ++++++++++++--- src/main.rs | 19 +++++++++---------- src/oauth/callback.rs | 13 ++++++++++--- tests/i18n.rs | 24 ++++++++++++++++++++---- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/i18n.rs b/src/i18n.rs index d194118..cd4bb9d 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -213,7 +213,10 @@ mod tests { #[test] fn locale_precedence_and_empty_values_are_explicit() { assert_eq!(choose_locale(Some("en"), &[Some("zh-CN")]), Locale::En); - assert_eq!(choose_locale(None, &[Some("zh-CN"), Some("C")]), Locale::ZhCn); + assert_eq!( + choose_locale(None, &[Some("zh-CN"), Some("C")]), + Locale::ZhCn + ); assert_eq!( choose_locale(None, &[None, Some("C"), Some("zh-CN")]), Locale::En @@ -222,7 +225,10 @@ mod tests { choose_locale(None, &[Some(""), Some(" "), Some("zh-CN")]), Locale::ZhCn ); - assert_eq!(choose_locale(None, &[Some("fr"), Some("zh-CN")]), Locale::En); + assert_eq!( + choose_locale(None, &[Some("fr"), Some("zh-CN")]), + Locale::En + ); assert_eq!(choose_locale(None, &[]), Locale::En); } @@ -259,7 +265,10 @@ mod tests { #[test] fn unknown_keys_are_safe_and_requested_locale_does_not_change_global_state() { assert_eq!(text(Locale::ZhCn, "missing.key"), "missing.key"); - assert_ne!(text(Locale::En, "cli.about"), text(Locale::ZhCn, "cli.about")); + assert_ne!( + text(Locale::En, "cli.about"), + text(Locale::ZhCn, "cli.about") + ); assert_eq!(locale(), Locale::En); } diff --git a/src/main.rs b/src/main.rs index d6e8b4c..67da153 100644 --- a/src/main.rs +++ b/src/main.rs @@ -307,7 +307,8 @@ fn operation_report( fn write_json(value: &impl Serialize) -> Result<(), String> { let mut output = io::stdout().lock(); - serde_json::to_writer_pretty(&mut output, value).map_err(|_| tr("error.json_output").to_owned())?; + serde_json::to_writer_pretty(&mut output, value) + .map_err(|_| tr("error.json_output").to_owned())?; writeln!(output).map_err(|_| tr("error.json_output").to_owned()) } @@ -488,7 +489,7 @@ fn execute(cli: &Cli) -> Result { |url| { let mut output = io::stderr().lock(); writeln!(output, "{}", trf!("login.authorize", url = url)) - .map_err(|_| oauth::AuthError::Output)?; + .map_err(|_| oauth::AuthError::Output)?; if !args.no_browser && open::that_detached(url).is_err() { writeln!(output, "{}", tr("login.browser_failed")) .map_err(|_| oauth::AuthError::Output)?; @@ -585,14 +586,12 @@ fn execute(cli: &Cli) -> Result { fn auth_failure(error: oauth::AuthError, json: bool) -> Result { if json { - write_json( - &serde_json::json!({ - "schema_version": 1, - "outcome": "blocked", - "error": error.to_string(), - "error_code": error.code(), - }), - )?; + write_json(&serde_json::json!({ + "schema_version": 1, + "outcome": "blocked", + "error": error.to_string(), + "error_code": error.code(), + }))?; } else { eprintln!("lmm: {error}"); } diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index 1ac42fe..b797ce2 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -75,7 +75,11 @@ fn callback_response(accepted: bool, locale: Locale) -> String { } else { i18n::text(locale, "callback.invalid") }; - let status = if accepted { "200 OK" } else { "400 Bad Request" }; + let status = if accepted { + "200 OK" + } else { + "400 Bad Request" + }; format!( "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nContent-Language: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'none'; frame-ancestors 'none'\r\nConnection: close\r\n\r\n{body}", body.len(), @@ -214,9 +218,12 @@ mod tests { for accepted in [true, false] { let response = callback_response(accepted, locale); let (headers, body) = response.split_once("\r\n\r\n").unwrap(); - let length: usize = headers.lines() + let length: usize = headers + .lines() .find_map(|line| line.strip_prefix("Content-Length: ")) - .unwrap().parse().unwrap(); + .unwrap() + .parse() + .unwrap(); assert_eq!(length, body.len()); assert!(headers.contains(&format!("Content-Language: {}", locale.tag()))); assert!(headers.contains("Content-Type: text/plain; charset=utf-8")); diff --git a/tests/i18n.rs b/tests/i18n.rs index d15e729..25479f8 100644 --- a/tests/i18n.rs +++ b/tests/i18n.rs @@ -57,10 +57,16 @@ fn environment_precedence_normalization_and_fallback_work_in_real_processes() { (vec![], false), (vec![("LANG", "zh_CN.UTF-8")], true), (vec![("LC_ALL", "C"), ("LANG", "zh_CN.UTF-8")], false), - (vec![("LC_MESSAGES", "zh-Hans"), ("LANG", "en_US.UTF-8")], true), + ( + vec![("LC_MESSAGES", "zh-Hans"), ("LANG", "en_US.UTF-8")], + true, + ), (vec![("LMM_LANG", "en"), ("LC_ALL", "zh_CN.UTF-8")], false), (vec![("LMM_LANG", ""), ("LANG", "zh_CN.UTF-8")], true), - (vec![("LMM_LANG", "unknown"), ("LANG", "zh_CN.UTF-8")], false), + ( + vec![("LMM_LANG", "unknown"), ("LANG", "zh_CN.UTF-8")], + false, + ), (vec![("LANG", "zh-TW")], false), ] { let output = run(&["--help"], &environment); @@ -74,7 +80,14 @@ fn translated_prose_does_not_change_json_keys_statuses_or_exit_codes() { let mut reports = Vec::new(); for language in ["en", "zh-CN"] { let output = run( - &["setup", "astrbot", "--dry-run", "--json", "--lang", language], + &[ + "setup", + "astrbot", + "--dry-run", + "--json", + "--lang", + language, + ], &[], ); assert_eq!(output.status.code(), Some(3)); @@ -90,7 +103,10 @@ fn translated_prose_does_not_change_json_keys_statuses_or_exit_codes() { ); report.as_object_mut().unwrap().remove("checked_at"); report.as_object_mut().unwrap().remove("notes"); - report["plans"][0].as_object_mut().unwrap().remove("blockers"); + report["plans"][0] + .as_object_mut() + .unwrap() + .remove("blockers"); reports.push(report); } assert_eq!(reports[0], reports[1]);