diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..995ab17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the pinned formatter + shell: bash + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("mise.toml", "rb"))["tools"]["rust"]["version"])') + rustup toolchain install "$version" --profile minimal --component rustfmt + rustup override set "$version" + - name: Check formatting + run: | + cargo fmt --all + git diff --exit-code > "$RUNNER_TEMP/rustfmt.patch" + - name: Keep formatting corrections on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: rustfmt-patch + path: ${{ runner.temp }}/rustfmt.patch + retention-days: 7 + if-no-files-found: ignore + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the pinned Rust toolchain + shell: bash + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("mise.toml", "rb"))["tools"]["rust"]["version"])') + rustup toolchain install "$version" --profile minimal --component clippy + rustup override set "$version" + - name: Check translations + run: python3 scripts/check-i18n.py + - name: Run tests + run: | + set -o pipefail + cargo test --locked 2>&1 | tee "$RUNNER_TEMP/tests.log" + - name: Check lints + run: | + set -o pipefail + cargo clippy --locked --all-targets --all-features -- -D warnings 2>&1 | tee "$RUNNER_TEMP/clippy.log" + - name: Verify source package + run: cargo package --locked + - name: Keep check logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: check-logs + path: ${{ runner.temp }}/*.log + retention-days: 7 + if-no-files-found: ignore + - name: Keep the checked source package + uses: actions/upload-artifact@v4 + with: + name: lmm-cli-source-${{ github.sha }} + path: target/package/*.crate + retention-days: 7 + if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md index 67a1e02..b4cf7d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,8 @@ fixtures instead of real account, system configuration or production writes. Run fmt, clippy and the complete CLI tests locally. Report the tested commit and skipped checks. A source change does not authorize publishing a crate, release or production deployment. + +Keep user-facing text in locales/en.json and locales/zh-CN.json, not Rust source. +Use stable message keys and named placeholders. Do not translate command names, +JSON field names, identifiers or protocol values. Run scripts/check-i18n.py and +read docs/i18n.md when adding messages. Keep both README versions in sync. diff --git a/Cargo.toml b/Cargo.toml index aeaf9ce..25ecdfe 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/**", "mise.toml", "LICENSE", "src/**", "tests/**", "docs/**", "NOTICE", "third_party/**", "scripts/*.py"] [lib] name = "lmm" diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..4c6d793 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,122 @@ +{ + "arg.activate": "Request adding and selecting LMM; this does not mean it has been applied", + "arg.add_only": "Add LMM without changing the current selection", + "arg.all": "Include only installed, supported targets; updates also require management approval", + "arg.dry_run": "Create a plan without applying changes", + "arg.fix": "Request approved LMM repairs without bypassing capability checks", + "arg.instance": "Explicit local instance root; remote and Docker instances are not scanned", + "arg.issuer": "Trusted LMM HTTPS origin; set explicitly for a self-hosted server", + "arg.json": "Write JSON without CLI prompts or configuration contents", + "arg.lang": "Display language: en or zh-CN; overrides LMM_LANG and locale variables", + "arg.no_browser": "Print the login URL; 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.query": "Search text; omit to list all entries", + "arg.report": "Create a shareable report without paths or configuration contents", + "arg.software": "Exact catalog ID, such as astrbot or cc-switch", + "arg.timeout": "Maximum wait for the browser callback, in seconds", + "arg.yes": "Approve the displayed scope, not account changes, provider switches, or paid tests", + "auth.already_logged_in": "The CLI is already signed in; run lmm logout before switching accounts. Application grants will not be transferred", + "auth.busy": "Another LMM authorization operation is running, or a secure per-user lock could not be created", + "auth.callback": "Could not bind the local OAuth callback port", + "auth.credential_store": "Could not access the system vault. Unlock Keychain, Credential Manager, or Secret Service; plain-text files will not be used", + "auth.denied": "Authorization was cancelled or denied", + "auth.invalid_issuer": "issuer must be a canonical HTTPS domain origin without a port, path, query, or credentials", + "auth.invalid_response": "The LMM OAuth response was invalid; authorization has stopped", + "auth.network": "The LMM network request failed; check connectivity and whether the server has enabled the lmm client", + "auth.not_logged_in": "Not signed in; run lmm login first", + "auth.output": "Could not display the login URL; login has stopped", + "auth.refresh_blocked": "The previous refresh did not complete reliably. The old refresh token will not be reused; run logout, then login", + "auth.timeout": "Login timed out; no further requests were sent", + "auth.unauthorized": "The server rejected authorization; sign in again or check that the lmm client is deployed", + "auth.unstored_grant": "The new grant could not be saved and revocation could not be confirmed; check LMM CLI on the LMM authorization page", + "callback.invalid": "Invalid or denied authorization. Return to LMM CLI.", + "callback.received": "Authorization received. Return to LMM CLI to check completion.", + "cli.about": "LMM: provider presets, software discovery, and read-only OAuth (development preview)", + "cmd.catalog": "Search the software catalog; entries are not verified integrations", + "cmd.disconnect": "Plan removal of an integration and its application grant", + "cmd.doctor": "Run read-only checks; --fix still requires adapter support and approval", + "cmd.help": "Print help for a command", + "cmd.install": "Plan software installation; LMM login is not required", + "cmd.login": "Sign in with a browser and store credentials in the system vault", + "cmd.logout": "Sign out of the CLI without revoking other application grants", + "cmd.models": "Read authorized models and prices without calling or selecting a model", + "cmd.presets": "Search the built-in provider snapshot without reading credentials or using the network", + "cmd.restore": "Plan field-level restoration while preserving later user edits", + "cmd.run": "Request a launch with the selected software configuration", + "cmd.setup": "Preview setup; writes stay blocked until a verified adapter is available", + "cmd.status": "Inspect local environment and path evidence without making changes", + "cmd.sync": "Plan an LMM refresh while preserving the current provider and model", + "cmd.uninstall": "Plan removal of managed software; keep user data by default", + "cmd.update": "Plan updates for software explicitly placed under management", + "error.auth_unattended": "The system vault may need to be unlocked; OAuth commands do not support --non-interactive yet. No credentials or network were accessed", + "error.catalog_output": "Could not write the software catalog", + "error.config_shape": "not a bounded regular configuration file", + "error.config_size": "configuration exceeds size limit", + "error.instance_unsupported": "Instance selection is not supported for this software; --instance will not be ignored", + "error.invalid_instance": "--instance must be an absolute local path for this environment; remote and container adapters are not available", + "error.json_output": "Could not write JSON", + "error.login_interactive": "Login needs browser approval; run lmm login in an interactive terminal. Unattended mode will not wait or consent for you", + "error.login_output": "Could not write the login result", + "error.logout_output": "Could not write the logout result", + "error.menu_output": "Could not display the selection menu", + "error.models_output": "Could not write models", + "error.no_selection": "No target selected; no changes were made", + "error.output": "Could not write results", + "error.presets_output": "Could not write provider presets", + "error.presets_read": "Could not read the built-in provider presets", + "error.selection_input": "Could not read the selection", + "error.target_required": "Unattended mode requires a software ID or --all; no changes were made", + "error.unknown_software": "Unknown software ID; run lmm catalog to find the exact name", + "error.unsupported_language": "Unsupported language. Use en or zh-CN.", + "help.arguments": "Arguments", + "help.commands": "Commands", + "help.help": "Print help", + "help.options": "Options", + "help.template": "{about-with-newline}\nUsage: {usage}\n\n{all-args}{after-help}", + "help.version": "Print version", + "kind.local": "local", + "kind.relay": "relay", + "kind.remote": "remote", + "kind.vendor": "vendor", + "note.all_blocked": "No automatic integration adapter has been verified; --all has no executable targets. No software was installed or changed", + "note.doctor_incomplete": "Diagnosis is incomplete: only local path evidence was checked. Check the selected instance, version, and configuration manager. No network or paid tests were run", + "note.dry_run": "Plan only; blockers remain unresolved. This is not evidence of a completed integration", + "note.execution_blocked": "No safe execution adapter is available for this target; no software was started or removed, no configuration changed, and no grants revoked", + "note.fix_blocked": "No repair adapter has been verified; configuration was not changed", + "note.install": "Installation does not require LMM login; a verified installation adapter is not available yet", + "note.yes": "--yes does not bypass missing capabilities, authorization, or cost limits", + "observation.not_found": "Not found", + "observation.present": "Present", + "observation.unknown": "Unknown", + "output.authorization_url": "Approve LMM CLI in your browser (catalog and balance reads only; no application migration):\n{url}", + "output.blocked_plan": "\n{software} plan blocked: no configuration changes, paid calls, or restarts", + "output.browser_failed": "The browser could not be opened. Open the URL above in a browser on this machine.", + "output.catalog_entry": "{id} - {name}\n {purpose}\n Source: {source}\n Installation/integration: unavailable; maintenance: local evidence only; versions/platforms/scenarios: unverified\n", + "output.evidence": " {path}: {observation} (path evidence only)", + "output.login": "LMM CLI is signed in. Credentials are stored in the system vault. Applications have not been granted model-call permission; run lmm models to read the catalog.", + "output.logout": "CLI signed out. Other application grants were not revoked and cannot be listed by this version.", + "output.model_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.", + "output.models": "Authorized models (application compatibility is not verified; final costs depend on usage):", + "output.presets": "Built-in provider presets (Magpie snapshot {revision}); configuration and connectivity were not checked. No providers were added.", + "output.scope": "Scope: current user and runtime environment; checked at {time} (Unix seconds)", + "output.target": "\n{software}: installation cannot be confirmed; installation and configuration managers are unknown", + "output.verification": " LMM added/active: unknown; authorization, connectivity, application, streaming, and tool calls: not checked", + "plan.application_grant": "CLI login allows only catalog and balance reads; separate application authorization is not connected yet", + "plan.install_adapter": "A verified software version, download-source check, and installation adapter are required", + "plan.paid_verification": "Paid verification requires explicit consent, a known quote, and a server-enforced spending limit", + "plan.restore_adapter": "No target change journal or native transaction adapter is connected; backups cannot be overwritten directly", + "plan.revoked_grant": "The application grant was revoked; explicit approval is required to authorize it again", + "plan.unknown_selection": "The current provider is unknown; first read the selected instance configuration", + "plan.unverified_target": "The software version, configuration manager, and actual request path have not been verified", + "plan.write_adapter": "This version has no verified configuration-write adapter for the target software", + "purpose.astrbot": "Agent and bot runtime for messaging platforms", + "purpose.cc_switch": "Provider configuration management and local routing", + "purpose.coding": "Coding assistant for the terminal", + "purpose.pi": "Terminal agent; its LMM provider is a separate project", + "restore.conflict": "The field changed after LMM wrote it; restoration has stopped", + "restore.duplicate_field": "The change journal contains duplicate fields", + "restore.invalid_pointer": "Could not locate the parent object for the field to restore", + "restore.unsupported_shape": "Only individual scalar fields in objects are supported; collections need a dedicated adapter", + "select.prompt": "Enter a number, or press Enter to cancel: ", + "select.title": "Select software to plan setup (no integration has been verified yet):" +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json new file mode 100644 index 0000000..8f93f29 --- /dev/null +++ b/locales/zh-CN.json @@ -0,0 +1,122 @@ +{ + "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;优先于 LMM_LANG 和系统语言变量", + "arg.no_browser": "只显示登录链接;仍需要浏览器能访问当前机器的回环端口", + "arg.non_interactive": "无人值守:缺少目标或授权时直接返回", + "arg.query": "搜索关键词;留空则列出全部条目", + "arg.report": "生成可主动分享的摘要,去除路径和配置内容", + "arg.software": "精确的软件目录 ID,例如 astrbot 或 cc-switch", + "arg.timeout": "浏览器回调等待上限(秒)", + "arg.yes": "确认已展示范围;不代表允许切换账号、服务商或付费测试", + "auth.already_logged_in": "已有 CLI 登录;切换账号请先 lmm logout,不会迁移应用授权", + "auth.busy": "另一项 LMM 授权操作正在运行,或无法建立当前用户的安全锁", + "auth.callback": "无法绑定本机 OAuth 回调端口", + "auth.credential_store": "无法访问系统凭据库;请解锁 Keychain、Credential Manager 或 Secret Service,不会回退到明文文件", + "auth.denied": "授权已取消或被拒绝", + "auth.invalid_issuer": "issuer 必须是规范的 HTTPS 域名 origin,不含端口、路径、查询或凭据", + "auth.invalid_response": "LMM OAuth 响应不符合约定,已停止授权", + "auth.network": "LMM 网络请求失败;请检查网络和服务端是否已启用 lmm 客户端", + "auth.not_logged_in": "尚未登录;请先运行 lmm login", + "auth.output": "无法输出登录入口;登录已停止", + "auth.refresh_blocked": "上次刷新未可靠完成;不会重放旧刷新令牌,请先 logout 再 login", + "auth.timeout": "登录等待超时;没有继续发起请求", + "auth.unauthorized": "服务端返回未授权;请重新登录,或检查 lmm 客户端是否已部署", + "auth.unstored_grant": "新授权未能保存,且无法确认已撤销;请在 LMM 授权页检查 LMM CLI 授权", + "callback.invalid": "授权无效或已被拒绝。请返回 LMM CLI。", + "callback.received": "已收到授权。请返回 LMM CLI 查看是否完成登录。", + "cli.about": "LMM 安装与接入工具(开发预览:环境检查与操作规划)", + "cmd.catalog": "搜索软件目录;目录条目不代表已验证兼容", + "cmd.disconnect": "解除指定软件接入并撤销对应授权", + "cmd.doctor": "只读诊断;--fix 仍受适配器能力与确认范围限制", + "cmd.help": "显示指定命令的帮助", + "cmd.install": "安装软件;不要求 LMM 登录", + "cmd.login": "通过浏览器登录,凭据保存在系统凭据库", + "cmd.logout": "退出 CLI;不撤销独立应用授权", + "cmd.models": "读取当前授权模型与计费信息,不发起模型调用或更改选择", + "cmd.presets": "搜索内置服务商预设快照;不读取配置、凭据或网络", + "cmd.restore": "按字段恢复 LMM 变更,保留后续修改", + "cmd.run": "按当前配置启动指定软件", + "cmd.setup": "规划完整接入;当前版本在未具备适配器时明确阻止写入", + "cmd.status": "只读检查环境、安装线索与各层验证状态", + "cmd.sync": "同步 LMM 信息,保留当前服务商与模型", + "cmd.uninstall": "卸载已确认纳入管理的软件,默认保留数据", + "cmd.update": "更新明确纳入管理的软件", + "error.auth_unattended": "当前系统凭据库可能需要解锁交互;OAuth 命令尚不支持 --non-interactive,未访问凭据或网络", + "error.catalog_output": "无法输出目录", + "error.config_shape": "配置不是大小受限的普通文件", + "error.config_size": "配置超出大小限制", + "error.instance_unsupported": "此软件尚未实现实例选择,不能忽略 --instance 后继续操作", + "error.invalid_instance": "--instance 必须是当前环境的绝对本地路径;远程与容器适配器尚未提供", + "error.json_output": "无法输出 JSON", + "error.login_interactive": "登录需要浏览器授权;请在交互终端运行 lmm login。无人值守模式不会等待或代替用户同意", + "error.login_output": "无法输出登录结果", + "error.logout_output": "无法输出退出结果", + "error.menu_output": "无法显示选择菜单", + "error.models_output": "无法输出模型", + "error.no_selection": "未选择目标;没有执行任何修改", + "error.output": "无法输出结果", + "error.presets_output": "无法输出服务商预设", + "error.presets_read": "内置服务商预设无法读取", + "error.selection_input": "无法读取选择", + "error.target_required": "无人值守模式需要明确软件或 --all;没有执行任何修改", + "error.unknown_software": "未知软件 ID;运行 lmm catalog 查看精确名称", + "error.unsupported_language": "不支持此语言。请使用 en 或 zh-CN。", + "help.arguments": "参数", + "help.commands": "命令", + "help.help": "显示帮助", + "help.options": "选项", + "help.template": "{about-with-newline}\n用法:{usage}\n\n{all-args}{after-help}", + "help.version": "显示版本", + "kind.local": "本地", + "kind.relay": "中转", + "kind.remote": "远程", + "kind.vendor": "服务商", + "note.all_blocked": "当前版本没有经过验证的自动接入适配器,--all 没有可执行目标;未安装或修改任何软件", + "note.doctor_incomplete": "诊断未完成:仅检查本地路径线索;请核对目标实例、版本及配置管理关系。没有执行网络或付费测试", + "note.dry_run": "仅规划;阻塞项仍需解决,不能作为接入完成的证据", + "note.execution_blocked": "未实现该目标的安全执行适配器;未启动或卸载软件,未修改配置或撤销授权", + "note.fix_blocked": "当前没有经过验证的修复适配器;未修改配置", + "note.install": "安装本身不需要 LMM 登录;当前缺少经过验证的安装适配器", + "note.yes": "--yes 不会跳过缺失能力、授权或费用限制", + "observation.not_found": "未找到", + "observation.present": "存在", + "observation.unknown": "未知", + "output.authorization_url": "请在浏览器确认 LMM CLI 授权(仅目录和余额读取,不迁移任何应用):\n{url}", + "output.blocked_plan": "\n{software} 计划已阻止:无配置修改、无付费调用、无重启", + "output.browser_failed": "浏览器未能自动打开,请在本机浏览器打开上述链接。", + "output.catalog_entry": "{id} — {name}\n {purpose}\n 来源:{source}\n 安装/接入:尚未支持;维护:仅本地线索检查;兼容版本/平台/场景:未验证\n", + "output.evidence": " {path}:{observation}(仅路径线索)", + "output.login": "LMM CLI 已登录,凭据已保存到系统凭据库。尚未授予应用调用权限;可运行 lmm models。", + "output.logout": "CLI 已退出;其他应用授权未撤销,当前版本尚不能列出这些授权。", + "output.model_entry": "{id} / {upstream} / {group}\n 价格单位:{currency} / {unit};输入:{input},输出:{output},每次请求:{request};依据:{basis}\n null/None 表示未知;未调用或切换模型。", + "output.models": "当前授权模型(不代表目标软件兼容;价格可能随使用变化):", + "output.presets": "内置服务商预设(Magpie 快照 {revision});配置与连通性未检查,未添加服务商。", + "output.scope": "范围:当前用户、当前运行环境;检查时间:{time}(Unix 秒)", + "output.target": "\n{software}:安装状态无法确认;安装管理者、配置管理者均未知", + "output.verification": " LMM 添加/启用:未知;授权、连通性、目标调用、流式及工具调用:未检查", + "plan.application_grant": "CLI 登录只允许读取目录和余额;目标应用独立授权尚未接入", + "plan.install_adapter": "缺少经过验证的软件版本、下载来源校验及安装适配器", + "plan.paid_verification": "付费验证需要明确同意、已知报价和服务端可执行的消费上限", + "plan.restore_adapter": "尚未接入目标的变更日志与原生事务适配器,不能直接覆盖备份", + "plan.revoked_grant": "应用授权已撤销;需要明确确认重新授权", + "plan.unknown_selection": "无法确认当前服务商;需要先读取选定实例的实际配置", + "plan.unverified_target": "未验证软件版本、配置管理关系及实际调用路径", + "plan.write_adapter": "此版本尚无经过验证的目标软件写入适配器", + "purpose.astrbot": "消息平台 Agent 与机器人运行环境", + "purpose.cc_switch": "服务商配置管理与本地路由", + "purpose.coding": "终端编程助手", + "purpose.pi": "终端 Agent,LMM Provider 已有独立项目", + "restore.conflict": "字段在 LMM 修改后再次变化,恢复已暂停", + "restore.duplicate_field": "变更记录包含重复字段", + "restore.invalid_pointer": "无法定位恢复字段的父对象", + "restore.unsupported_shape": "仅支持对象中的单个标量字段;集合需要专用适配器", + "select.prompt": "输入序号,或直接回车取消:", + "select.title": "选择要规划接入的软件(当前均未完成集成验证):" +} diff --git a/scripts/check-i18n.py b/scripts/check-i18n.py new file mode 100644 index 0000000..6d0cf5b --- /dev/null +++ b/scripts/check-i18n.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Check embedded translations with only the Python standard library.""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +HAN = re.compile("[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\U00020000-\U0003134f]") +PLACEHOLDER = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_-]*)\}") +CALL_KEY = re.compile(r'(?\s*"([a-z]+\.[a-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 translation key: {key}") + result[key] = value + return result + + +def placeholders(value: str) -> list[str]: + remainder = PLACEHOLDER.sub("", value) + if "{" in remainder or "}" in remainder: + raise ValueError(f"invalid placeholder syntax: {value!r}") + return sorted(PLACEHOLDER.findall(value)) + + +def main() -> None: + catalogs = { + path.stem: json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + for path in sorted((ROOT / "locales").glob("*.json")) + } + reference = catalogs["en"] + for locale, catalog in catalogs.items(): + if set(catalog) != set(reference): + raise ValueError(f"{locale}: missing={set(reference) - set(catalog)}, extra={set(catalog) - set(reference)}") + for key, value in catalog.items(): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{locale}.{key}: expected a nonempty string") + if placeholders(value) != placeholders(reference[key]): + raise ValueError(f"{locale}.{key}: placeholders differ from English") + used: set[str] = set() + for path in (ROOT / "src").rglob("*.rs"): + source = path.read_text(encoding="utf-8") + if HAN.search(source): + raise ValueError(f"{path.relative_to(ROOT)}: move Chinese UI text into locales/") + used.update(CALL_KEY.findall(source)) + used.update(MATCH_KEY.findall(source)) + unknown = used - set(reference) + unused = set(reference) - used + if unknown or unused: + raise ValueError(f"unknown message keys={unknown}; unused message keys={unused}") + print(f"i18n: {len(catalogs)} catalogs, {len(reference)} keys; placeholders and runtime references verified") + + +if __name__ == "__main__": + main() diff --git a/src/catalog.rs b/src/catalog.rs index 3b5c12c..6a18b78 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -1,5 +1,6 @@ //! Explicit project identities and independently stated integration capabilities. +use crate::i18n::{Locale, Message, msg}; use serde::Serialize; /// A capability with no verified adapter must never be promoted by discovery. @@ -22,7 +23,7 @@ pub struct Software { /// Canonical project identity, not a download mirror. pub source: &'static str, /// Intended use. - pub purpose: &'static str, + pub purpose: Message, /// Installation capability of this LMM release. pub installation: Capability, /// Integration capability of this LMM release. @@ -39,7 +40,7 @@ const fn software( id: &'static str, name: &'static str, source: &'static str, - purpose: &'static str, + purpose: Message, executables: &'static [&'static str], ) -> Software { Software { @@ -61,42 +62,42 @@ pub static SOFTWARE: &[Software] = &[ "astrbot", "AstrBot", "https://github.com/AstrBotDevs/AstrBot", - "消息平台 Agent 与机器人运行环境", + msg("purpose.astrbot"), &["astrbot"], ), software( "cc-switch", "CC Switch", "https://github.com/farion1231/cc-switch", - "服务商配置管理与本地路由", + msg("purpose.cc_switch"), &["cc-switch"], ), software( "claude-code", "Claude Code CLI", "https://github.com/anthropics/claude-code", - "终端编程助手", + msg("purpose.coding"), &["claude"], ), software( "codex", "OpenAI Codex CLI", "https://github.com/openai/codex", - "终端编程助手", + msg("purpose.coding"), &["codex"], ), software( "gemini-cli", "Gemini CLI", "https://github.com/google-gemini/gemini-cli", - "终端编程助手", + msg("purpose.coding"), &["gemini"], ), software( "pi", "Pi coding agent", "https://github.com/badlogic/pi-mono", - "终端 Agent,LMM Provider 已有独立项目", + msg("purpose.pi"), &["pi"], ), ]; @@ -108,13 +109,19 @@ pub fn find(id: &str) -> Option<&'static Software> { /// Search project identity and purpose without network access. pub fn search(query: &str) -> Vec<&'static Software> { - let query = query.to_lowercase(); + let query = query.trim().to_lowercase(); 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| { + locale + .text(item.purpose.key()) + .to_lowercase() + .contains(&query) + }) }) .collect() } diff --git a/src/discovery.rs b/src/discovery.rs index 2e1bf22..72398a5 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,6 +1,9 @@ //! Bounded local probes. Finding a path is evidence, not proof of installation. -use crate::catalog::Software; +use crate::{ + catalog::Software, + i18n::{Message, msg}, +}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::{ @@ -24,6 +27,17 @@ pub enum Observation { Unknown, } +impl Observation { + /// Localized display label; serialized observation values remain unchanged. + pub const fn label(self) -> Message { + msg(match self { + Self::Present => "observation.present", + Self::NotFound => "observation.not_found", + Self::Unknown => "observation.unknown", + }) + } +} + /// Public evidence contains no configuration values or credentials. #[derive(Debug, Serialize)] pub struct Evidence { @@ -189,7 +203,7 @@ pub fn fingerprint(path: &Path) -> io::Result { if !meta.is_file() || meta.len() > MAX_CONFIG_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, - "not a bounded regular configuration file", + msg("error.config_shape"), )); } let mut data = Vec::new(); @@ -199,7 +213,7 @@ pub fn fingerprint(path: &Path) -> io::Result { if data.len() as u64 > MAX_CONFIG_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, - "configuration exceeds size limit", + msg("error.config_size"), )); } Ok(format!("{:x}", Sha256::digest(data))) diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..4280a13 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,218 @@ +//! Embedded CLI translations. Machine-readable messages always use English. + +use serde::{Serialize, Serializer}; +use std::{collections::BTreeMap, ffi::OsString, fmt, str::FromStr, sync::OnceLock}; + +type Catalog = BTreeMap; +static ENGLISH: OnceLock = OnceLock::new(); +static CHINESE: OnceLock = OnceLock::new(); +static LANGUAGE: OnceLock = OnceLock::new(); + +/// Languages shipped with the binary; no files or network are needed at runtime. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Locale { + #[default] + En, + ZhCn, +} + +impl Locale { + pub const ALL: [Self; 2] = [Self::En, Self::ZhCn]; + + /// Recognize supported language tags and common POSIX locale names. + pub fn from_name(value: &str) -> Option { + let name = value + .trim() + .split(['.', '@']) + .next() + .unwrap_or_default() + .replace('_', "-") + .to_ascii_lowercase(); + match name.as_str() { + "c" | "posix" | "en" => Some(Self::En), + "zh" | "zh-cn" | "zh-sg" | "zh-hans" => Some(Self::ZhCn), + name if name.starts_with("en-") => Some(Self::En), + name if name.starts_with("zh-hans-") => Some(Self::ZhCn), + _ => None, + } + } + + /// Resolve language before clap renders help. Clap still validates all arguments. + /// Stop at `--` so positional data cannot change the language. + pub fn detect(args: &[OsString], get_env: impl Fn(&str) -> Option) -> Self { + let mut selected = None; + let mut args = args.iter().skip(1); + while let Some(arg) = args.next() { + if arg == "--" { + break; + } + let value = if arg == "--lang" { + args.next().and_then(|value| value.to_str()) + } else { + arg.to_str().and_then(|arg| arg.strip_prefix("--lang=")) + }; + if let Some(value) = value { + selected = Self::from_name(value); + } + } + if let Some(locale) = selected { + return locale; + } + for key in ["LMM_LANG", "LC_ALL", "LC_MESSAGES", "LANG"] { + if let Some(value) = get_env(key).filter(|value| !value.is_empty()) { + return value.to_str().and_then(Self::from_name).unwrap_or_default(); + } + } + Self::default() + } + + fn catalog(self) -> &'static Catalog { + let (cache, source) = match self { + Self::En => (&ENGLISH, include_str!("../locales/en.json")), + Self::ZhCn => (&CHINESE, include_str!("../locales/zh-CN.json")), + }; + cache.get_or_init(|| { + serde_json::from_str(source) + .unwrap_or_else(|error| panic!("invalid embedded translation catalog: {error}")) + }) + } + + /// Missing translations fall back to English, then to the visible message key. + pub fn text(self, key: &'static str) -> &'static str { + self.catalog() + .get(key) + .or_else(|| Self::En.catalog().get(key)) + .map_or(key, String::as_str) + } +} + +impl FromStr for Locale { + type Err = Message; + + fn from_str(value: &str) -> Result { + Self::from_name(value).ok_or(msg("error.unsupported_language")) + } +} + +/// Select once at process startup. Tests should use explicit locales instead. +pub fn initialize(locale: Locale) -> Result<(), Locale> { + LANGUAGE.set(locale) +} + +pub fn locale() -> Locale { + LANGUAGE.get().copied().unwrap_or_default() +} + +/// A stable message key, separated from both presentation and serialization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Message(&'static str); + +pub const fn msg(key: &'static str) -> Message { + Message(key) +} + +pub fn text(key: &'static str) -> &'static str { + msg(key).text() +} + +impl Message { + pub const fn key(self) -> &'static str { + self.0 + } + + pub fn text(self) -> &'static str { + locale().text(self.0) + } + + /// Substitute named values once; inserted text is never parsed as a template. + pub fn render(self, values: &[(&str, &str)]) -> String { + self.render_in(locale(), values) + } + + pub fn render_in(self, locale: Locale, values: &[(&str, &str)]) -> String { + let mut rest = locale.text(self.0); + let mut output = String::with_capacity(rest.len()); + while let Some(start) = rest.find('{') { + output.push_str(&rest[..start]); + let Some(end) = rest[start..].find('}').map(|end| start + end) else { + output.push_str(&rest[start..]); + return output; + }; + let name = &rest[start + 1..end]; + match values.iter().find(|(key, _)| *key == name) { + Some((_, value)) => output.push_str(value), + None => output.push_str(&rest[start..=end]), + } + rest = &rest[end + 1..]; + } + output.push_str(rest); + output + } +} + +impl fmt::Display for Message { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.text()) + } +} + +impl std::error::Error for Message {} + +impl Serialize for Message { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(Locale::En.text(self.0)) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn shipped_catalogs_have_the_same_nonempty_keys() { + let reference = Locale::En.catalog(); + assert!(!reference.is_empty()); + for locale in Locale::ALL { + let catalog = locale.catalog(); + assert_eq!( + reference.keys().collect::>(), + catalog.keys().collect::>() + ); + assert!(catalog.values().all(|value| !value.trim().is_empty())); + } + } + + #[test] + fn unknown_keys_remain_visible() { + assert_eq!(Locale::ZhCn.text("missing.test.key"), "missing.test.key"); + } + + #[test] + fn replacement_values_cannot_inject_other_placeholders() { + let output = msg("output.catalog_entry").render_in( + Locale::En, + &[ + ("id", "{source}"), + ("name", "name"), + ("purpose", "purpose"), + ("source", "URL"), + ], + ); + assert!(output.starts_with("{source} - name")); + assert!(output.contains("Source: URL")); + } + + #[test] + fn serialization_does_not_depend_on_display_language() { + let message = msg("error.unknown_software"); + assert_ne!( + Locale::En.text(message.key()), + Locale::ZhCn.text(message.key()) + ); + assert_eq!( + serde_json::to_value(message).unwrap(), + Locale::En.text(message.key()) + ); + } +} 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..10ebc57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ -use clap::{Args, Parser, Subcommand}; +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use lmm::{ catalog, discovery::{self, ProbeContext, TargetStatus}, environment::{self, Environment}, + i18n::{self, Locale, Message, msg, text}, oauth, plan::{Intent, Preview}, provider, @@ -20,13 +21,15 @@ use std::{ #[command( name = "lmm", version, - about = "LMM 安装与接入工具(开发预览:环境检查与操作规划)" + about = text("cli.about") )] struct Cli { - /// 输出结构化 JSON;不交互、不输出配置内容 + #[arg(long, global = true, value_name = "LANG", help = text("arg.lang"))] + lang: Option, + #[arg(help = text("arg.json"))] #[arg(long, global = true)] json: bool, - /// 无人值守:缺少目标或授权时直接返回 + #[arg(help = text("arg.non_interactive"))] #[arg(long, global = true)] non_interactive: bool, #[command(subcommand)] @@ -35,41 +38,47 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// 搜索内置服务商预设快照;不读取配置、凭据或网络 - Presets { query: Option }, - /// 搜索软件目录;目录条目不代表已验证兼容 - Catalog { query: Option }, - /// 规划完整接入;当前版本在未具备适配器时明确阻止写入 + #[command(about = text("cmd.presets"))] + Presets { + #[arg(help = text("arg.query"))] + query: Option, + }, + #[command(about = text("cmd.catalog"))] + Catalog { + #[arg(help = text("arg.query"))] + query: Option, + }, + #[command(about = text("cmd.setup"))] Setup(SetupArgs), - /// 安装软件;不要求 LMM 登录 + #[command(about = text("cmd.install"))] Install(OperationArgs), - /// 通过浏览器登录,凭据保存在系统凭据库 + #[command(about = text("cmd.login"))] Login(LoginArgs), - /// 退出 CLI;不撤销独立应用授权 + #[command(about = text("cmd.logout"))] Logout(AuthArgs), - /// 读取当前授权模型与计费信息,不发起模型调用或更改选择 + #[command(about = text("cmd.models"))] Models(AuthArgs), - /// 只读检查环境、安装线索与各层验证状态 + #[command(about = text("cmd.status"))] Status(TargetArgs), - /// 同步 LMM 信息,保留当前服务商与模型 + #[command(about = text("cmd.sync"))] Sync(OperationArgs), - /// 更新明确纳入管理的软件 + #[command(about = text("cmd.update"))] Update(OperationArgs), - /// 只读诊断;--fix 仍受适配器能力与确认范围限制 + #[command(about = text("cmd.doctor"))] Doctor(DoctorArgs), - /// 按字段恢复 LMM 变更,保留后续修改 + #[command(about = text("cmd.restore"))] Restore(OperationArgs), - /// 解除指定软件接入并撤销对应授权 + #[command(about = text("cmd.disconnect"))] Disconnect(RequiredTarget), - /// 卸载已确认纳入管理的软件,默认保留数据 + #[command(about = text("cmd.uninstall"))] Uninstall(RequiredTarget), - /// 按当前配置启动指定软件 + #[command(about = text("cmd.run"))] Run(RequiredTarget), } #[derive(Args)] struct AuthArgs { - /// 信任的 LMM HTTPS origin(自托管时显式指定) + #[arg(help = text("arg.issuer"))] #[arg(long, default_value = oauth::DEFAULT_ISSUER)] issuer: String, } @@ -78,19 +87,19 @@ struct AuthArgs { struct LoginArgs { #[command(flatten)] auth: AuthArgs, - /// 只显示登录链接;仍需要浏览器能访问当前机器的回环端口 + #[arg(help = text("arg.no_browser"))] #[arg(long)] no_browser: bool, - /// 浏览器回调等待上限(秒) + #[arg(help = text("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 = text("arg.software"))] software: Option, - /// 明确的本地实例根目录;不自动扫描远程或 Docker 实例 + #[arg(help = text("arg.instance"))] #[arg(long, requires = "software")] instance: Option, } @@ -99,13 +108,13 @@ struct TargetArgs { struct OperationArgs { #[command(flatten)] target: TargetArgs, - /// 仅包含当前环境中已安装且受支持的目标;更新还要求管理授权 + #[arg(help = text("arg.all"))] #[arg(long, conflicts_with_all = ["software", "instance"])] all: bool, - /// 只生成计划,不执行修改 + #[arg(help = text("arg.dry_run"))] #[arg(long)] dry_run: bool, - /// 确认已展示范围;不代表允许切换账号、服务商或付费测试 + #[arg(help = text("arg.yes"))] #[arg(long)] yes: bool, } @@ -114,10 +123,10 @@ struct OperationArgs { struct SetupArgs { #[command(flatten)] operation: OperationArgs, - /// 仅添加 LMM,保留当前选择 + #[arg(help = text("arg.add_only"))] #[arg(long, conflicts_with = "activate")] add_only: bool, - /// 明确请求添加并切换到 LMM(不表示已执行) + #[arg(help = text("arg.activate"))] #[arg(long)] activate: bool, } @@ -126,18 +135,18 @@ struct SetupArgs { struct DoctorArgs { #[command(flatten)] target: TargetArgs, - /// 请求修复已获允许的 LMM 配置;不会绕过能力检查 + #[arg(help = text("arg.fix"))] #[arg(long)] fix: bool, - /// 生成可主动分享的摘要,去除路径和配置内容 - #[arg(long)] + #[arg(long, help = text("arg.report"))] report: bool, } #[derive(Args)] struct RequiredTarget { + #[arg(help = text("arg.software"))] software: String, - #[arg(long)] + #[arg(long, help = text("arg.instance"))] instance: Option, } @@ -151,7 +160,7 @@ struct Report { environment: Environment, targets: Vec, plans: Vec, - notes: Vec<&'static str>, + notes: Vec, } impl Report { @@ -193,20 +202,19 @@ fn context() -> ProbeContext { } } -fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result, String> { +fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result, Message> { if target .instance .as_ref() .is_some_and(|path| !path.is_absolute()) { - return Err("--instance 必须是当前环境的绝对本地路径;远程与容器适配器尚未提供".into()); + return Err(msg("error.invalid_instance")); } match target.software.as_deref() { Some(id) => { - let software = catalog::find(id) - .ok_or_else(|| "未知软件 ID;运行 lmm catalog 查看精确名称".to_owned())?; + let software = catalog::find(id).ok_or(msg("error.unknown_software"))?; if target.instance.is_some() && !matches!(id, "astrbot" | "cc-switch") { - return Err("此软件尚未实现实例选择,不能忽略 --instance 后继续操作".into()); + return Err(msg("error.instance_unsupported")); } Ok(vec![discovery::discover( software, @@ -221,20 +229,19 @@ fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result Result { +fn select_software() -> Result { let mut output = io::stderr().lock(); - writeln!(output, "选择要规划接入的软件(当前均未完成集成验证):") - .map_err(|_| "无法显示选择菜单")?; + writeln!(output, "{}", msg("select.title")).map_err(|_| msg("error.menu_output"))?; for (i, software) in catalog::SOFTWARE.iter().enumerate() { writeln!(output, " {}. {} ({})", i + 1, software.name, software.id) - .map_err(|_| "无法显示选择菜单")?; + .map_err(|_| msg("error.menu_output"))?; } - write!(output, "输入序号,或直接回车取消:").map_err(|_| "无法显示选择菜单")?; - output.flush().map_err(|_| "无法显示选择菜单")?; + write!(output, "{}", msg("select.prompt")).map_err(|_| msg("error.menu_output"))?; + output.flush().map_err(|_| msg("error.menu_output"))?; let mut input = String::new(); io::stdin() .read_line(&mut input) - .map_err(|_| "无法读取选择")?; + .map_err(|_| msg("error.selection_input"))?; let index = input .trim() .parse::() @@ -243,7 +250,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(msg("error.no_selection")) } fn operation_report( @@ -252,11 +259,11 @@ fn operation_report( intent: Intent, interactive: bool, context: &ProbeContext, -) -> Result { +) -> Result { let selected; let target = if args.target.software.is_none() && !args.all { if !interactive { - return Err("无人值守模式需要明确软件或 --all;没有执行任何修改".into()); + return Err(msg("error.target_required")); } selected = TargetArgs { software: Some(select_software()?), @@ -278,104 +285,97 @@ fn operation_report( let mut preview = Preview::blocked(target.software, intent); if name == "install" { preview.account = "not_required"; - preview.blockers = vec!["缺少经过验证的软件版本、下载来源校验及安装适配器"]; + preview.blockers = vec![msg("plan.install_adapter")]; } else if name == "restore" { preview.account = "not_required"; - preview.blockers = vec!["尚未接入目标的变更日志与原生事务适配器,不能直接覆盖备份"]; + preview.blockers = vec![msg("plan.restore_adapter")]; } report.plans.push(preview); } if args.all { - report.notes.push( - "当前版本没有经过验证的自动接入适配器,--all 没有可执行目标;未安装或修改任何软件", - ); + report.notes.push(msg("note.all_blocked")); } if args.dry_run { - report - .notes - .push("仅规划;阻塞项仍需解决,不能作为接入完成的证据"); + report.notes.push(msg("note.dry_run")); } if args.yes { - report.notes.push("--yes 不会跳过缺失能力、授权或费用限制"); + report.notes.push(msg("note.yes")); } if name == "install" { - report - .notes - .push("安装本身不需要 LMM 登录;当前缺少经过验证的安装适配器"); + report.notes.push(msg("note.install")); } Ok(report) } -fn write_json(value: &impl Serialize) -> Result<(), String> { +fn write_json(value: &impl Serialize) -> Result<(), Message> { 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(|_| msg("error.json_output"))?; + writeln!(output).map_err(|_| msg("error.json_output")) } -fn emit(report: &Report, json: bool) -> Result<(), String> { +fn emit(report: &Report, json: bool) -> Result<(), Message> { if json { return write_json(report); } let mut output = io::stdout().lock(); writeln!( output, - "LMM · {} · {} / {}", + "LMM - {} - {} / {}", report.command, report.environment.os, report.environment.arch ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| msg("error.output"))?; writeln!( output, - "范围:当前用户、当前运行环境;检查时间:{}(Unix 秒)", - report.checked_at + "{}", + msg("output.scope").render(&[("time", &report.checked_at.to_string())]) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| msg("error.output"))?; for target in &report.targets { writeln!( output, - "\n{}:安装状态无法确认;安装管理者、配置管理者均未知", - target.software + "{}", + msg("output.target").render(&[("software", target.software)]) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| msg("error.output"))?; for evidence in &target.evidence { - // Debug formatting escapes control characters in user-controlled paths. + // Debug formatting still escapes control characters in user-controlled paths. + let path = format!("{:?}", evidence.path); + let observation = evidence.observation.label(); writeln!( output, - " {:?}: {:?}(仅路径线索)", - evidence.path, evidence.observation + "{}", + msg("output.evidence") + .render(&[("path", &path), ("observation", observation.text())]) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| msg("error.output"))?; } - writeln!( - output, - " LMM 添加/启用:未知;授权、连通性、目标调用、流式及工具调用:未检查" - ) - .map_err(|_| "无法输出结果")?; + writeln!(output, "{}", msg("output.verification")).map_err(|_| msg("error.output"))?; } for plan in &report.plans { writeln!( output, - "\n{} 计划已阻止:无配置修改、无付费调用、无重启", - plan.software + "{}", + msg("output.blocked_plan").render(&[("software", plan.software)]) ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| msg("error.output"))?; for blocker in &plan.blockers { - writeln!(output, " - {blocker}").map_err(|_| "无法输出结果")?; + writeln!(output, " - {blocker}").map_err(|_| msg("error.output"))?; } } for note in &report.notes { - writeln!(output, "\n{note}").map_err(|_| "无法输出结果")?; + writeln!(output, "\n{note}").map_err(|_| msg("error.output"))?; } Ok(()) } -fn execute(cli: &Cli) -> Result { +fn execute(cli: &Cli) -> Result { if cli.non_interactive && matches!( &cli.command, Command::Login(_) | Command::Logout(_) | Command::Models(_) ) { - return Err("当前系统凭据库可能需要解锁交互;OAuth 命令尚不支持 --non-interactive,未访问凭据或网络".into()); + return Err(msg("error.auth_unattended")); } let context = context(); let interactive = !cli.json @@ -385,26 +385,26 @@ 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(|_| msg("error.presets_read"))?; if cli.json { write_json(&snapshot)?; } else { let mut output = io::stdout().lock(); writeln!( output, - "内置服务商预设(Magpie 快照 {});配置与连通性未检查,未添加服务商。", - provider::UPSTREAM_REVISION + "{}", + msg("output.presets").render(&[("revision", provider::UPSTREAM_REVISION)]) ) - .map_err(|_| "无法输出服务商预设")?; + .map_err(|_| msg("error.presets_output"))?; for preset in &snapshot.presets { writeln!( output, "{} — {} ({})", preset.id, preset.name, - preset.kind.as_str() + preset.kind.label() ) - .map_err(|_| "无法输出服务商预设")?; + .map_err(|_| msg("error.presets_output"))?; } } return Ok(0); @@ -416,7 +416,17 @@ 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, + "{}", + msg("output.catalog_entry").render(&[ + ("id", software.id), + ("name", software.name), + ("purpose", software.purpose.text()), + ("source", software.source), + ]) + ) + .map_err(|_| msg("error.catalog_output"))?; } } return Ok(0); @@ -425,12 +435,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(msg("note.doctor_incomplete")); if args.fix { report.outcome = "blocked"; - report - .notes - .push("当前没有经过验证的修复适配器;未修改配置"); + report.notes.push(msg("note.fix_blocked")); } if args.report { report.redact(); @@ -464,7 +472,7 @@ fn execute(cli: &Cli) -> Result { } Command::Login(args) => { if !interactive { - return Err("登录需要浏览器授权;请在交互终端运行 lmm login。无人值守模式不会等待或代替用户同意".into()); + return Err(msg("error.login_interactive")); } let result = oauth::login( &args.auth.issuer, @@ -473,11 +481,12 @@ fn execute(cli: &Cli) -> Result { let mut output = io::stderr().lock(); writeln!( output, - "请在浏览器确认 LMM CLI 授权(仅目录和余额读取,不迁移任何应用):\n{url}" + "{}", + msg("output.authorization_url").render(&[("url", url)]) ) .map_err(|_| oauth::AuthError::Output)?; if !args.no_browser && open::that_detached(url).is_err() { - writeln!(output, "浏览器未能自动打开,请在本机浏览器打开上述链接。") + writeln!(output, "{}", msg("output.browser_failed")) .map_err(|_| oauth::AuthError::Output)?; } Ok(()) @@ -488,8 +497,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(), "{}", msg("output.login")) + .map_err(|_| msg("error.login_output"))?; } Ok(0) } @@ -504,11 +513,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(), "{}", msg("output.logout")) + .map_err(|_| msg("error.logout_output"))?; } Ok(0) } @@ -522,13 +528,25 @@ fn execute(cli: &Cli) -> Result { write_json(&catalog)?; } else { let mut output = io::stdout().lock(); - writeln!( - output, - "当前授权模型(不代表目标软件兼容;价格可能随使用变化):" - ) - .map_err(|_| "无法输出模型")?; + writeln!(output, "{}", msg("output.models")) + .map_err(|_| msg("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, + "{}", + msg("output.model_entry").render(&[ + ("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(|_| msg("error.models_output"))?; } } Ok(0) @@ -548,9 +566,7 @@ fn execute(cli: &Cli) -> Result { }; let mut report = Report::new(name, inspect(&target, &context)?); report.outcome = "blocked"; - report - .notes - .push("未实现该目标的安全执行适配器;未启动或卸载软件,未修改配置或撤销授权"); + report.notes.push(msg("note.execution_blocked")); report } }; @@ -562,10 +578,10 @@ fn execute(cli: &Cli) -> Result { }) } -fn auth_failure(error: oauth::AuthError, json: bool) -> 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.message(),"error_code":error.message().key()}), )?; } else { eprintln!("lmm: {error}"); @@ -573,14 +589,43 @@ fn auth_failure(error: oauth::AuthError, json: bool) -> Result { Ok(3) } +fn localized_help(mut command: clap::Command) -> clap::Command { + command.build(); + if command.get_name() == "help" { + command = command.about(text("cmd.help")); + } + command + .help_template(text("help.template")) + .subcommand_help_heading(text("help.commands")) + .mut_args(|arg| { + let heading = if arg.is_positional() { + text("help.arguments") + } else { + text("help.options") + }; + let arg = match arg.get_id().as_str() { + "help" => arg.help(text("help.help")), + "version" => arg.help(text("help.version")), + _ => arg, + }; + arg.help_heading(heading) + }) + .mut_subcommands(localized_help) +} + fn main() -> ExitCode { - let cli = Cli::parse(); + let args: Vec<_> = env::args_os().collect(); + let selected = Locale::detect(&args, |key| env::var_os(key)); + let _ = i18n::initialize(selected); + let matches = localized_help(Cli::command()).get_matches_from(args); + let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); + debug_assert!(cli.lang.is_none_or(|locale| locale == selected)); match execute(&cli) { Ok(code) => ExitCode::from(code), Err(error) => { if cli.json { let _ = write_json( - &serde_json::json!({"schema_version": 1, "outcome": "invalid_request", "error": error}), + &serde_json::json!({"schema_version": 1, "outcome": "invalid_request", "error": error, "error_code": error.key()}), ); } else { eprintln!("lmm: {error}"); diff --git a/src/oauth.rs b/src/oauth.rs index 7e2dac9..e4e1b74 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -7,6 +7,7 @@ mod storage; #[cfg(test)] mod tests; +use crate::i18n::{Message, msg}; use protocol::{Credential, TokenResponse}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; @@ -15,7 +16,6 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; use storage::{SecretStore, StoredSession, SystemStore}; -use thiserror::Error; use zeroize::Zeroizing; /// Default trusted deployment issuer; never taken from HTTP redirects. @@ -24,40 +24,54 @@ const CLIENT_ID: &str = "lmm"; const SCOPE: &str = "catalog:read balance:read"; /// Errors contain fixed messages only: never HTTP bodies, URLs or credentials. -#[derive(Debug, Error, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub enum AuthError { - #[error("issuer 必须是规范的 HTTPS 域名 origin,不含端口、路径、查询或凭据")] InvalidIssuer, - #[error("LMM 网络请求失败;请检查网络和服务端是否已启用 lmm 客户端")] Network, - #[error("LMM OAuth 响应不符合约定,已停止授权")] InvalidResponse, - #[error("授权已取消或被拒绝")] Denied, - #[error("登录等待超时;没有继续发起请求")] Timeout, - #[error("无法绑定本机 OAuth 回调端口")] Callback, - #[error( - "无法访问系统凭据库;请解锁 Keychain、Credential Manager 或 Secret Service,不会回退到明文文件" - )] CredentialStore, - #[error("尚未登录;请先运行 lmm login")] NotLoggedIn, - #[error("已有 CLI 登录;切换账号请先 lmm logout,不会迁移应用授权")] AlreadyLoggedIn, - #[error("上次刷新未可靠完成;不会重放旧刷新令牌,请先 logout 再 login")] RefreshBlocked, - #[error("另一项 LMM 授权操作正在运行,或无法建立当前用户的安全锁")] Busy, - #[error("服务端返回未授权;请重新登录,或检查 lmm 客户端是否已部署")] Unauthorized, - #[error("新授权未能保存,且无法确认已撤销;请在 LMM 授权页检查 LMM CLI 授权")] UnstoredGrant, - #[error("无法输出登录入口;登录已停止")] Output, } +impl AuthError { + /// A fixed, non-sensitive description with a stable machine-readable key. + pub const fn message(&self) -> Message { + msg(match self { + Self::InvalidIssuer => "auth.invalid_issuer", + Self::Network => "auth.network", + Self::InvalidResponse => "auth.invalid_response", + Self::Denied => "auth.denied", + Self::Timeout => "auth.timeout", + Self::Callback => "auth.callback", + Self::CredentialStore => "auth.credential_store", + Self::NotLoggedIn => "auth.not_logged_in", + Self::AlreadyLoggedIn => "auth.already_logged_in", + Self::RefreshBlocked => "auth.refresh_blocked", + Self::Busy => "auth.busy", + Self::Unauthorized => "auth.unauthorized", + Self::UnstoredGrant => "auth.unstored_grant", + Self::Output => "auth.output", + }) + } +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.message(), formatter) + } +} + +impl std::error::Error for AuthError {} + /// 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..9f3422e 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -1,4 +1,5 @@ use super::AuthError; +use crate::i18n::text; use std::{ collections::BTreeMap, io::{Read, Write}, @@ -48,9 +49,9 @@ impl Callback { } 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." + text("callback.received") } else { - "Invalid or denied authorization. Return to LMM CLI." + text("callback.invalid") }; let status = if result.is_ok() { "200 OK" diff --git a/src/plan.rs b/src/plan.rs index 76e4cce..4668353 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::{Message, msg}; 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("{}", msg("plan.unknown_selection"))] UnknownSelection, /// A revoked application must not be silently granted permission again. - #[error("应用授权已撤销;需要明确确认重新授权")] + #[error("{}", msg("plan.revoked_grant"))] RevokedGrant, /// Cost checks must use a server-enforced reservation. - #[error("付费验证需要明确同意、已知报价和服务端可执行的消费上限")] + #[error("{}", msg("plan.paid_verification"))] PaidVerificationNotAuthorized, } @@ -110,7 +111,7 @@ pub struct Preview { /// No discovered service is restarted. pub restart: bool, /// Specific missing prerequisites. - pub blockers: Vec<&'static str>, + pub blockers: Vec, } impl Preview { @@ -130,9 +131,9 @@ impl Preview { paid_test: false, restart: false, blockers: vec![ - "此版本尚无经过验证的目标软件写入适配器", - "CLI 登录只允许读取目录和余额;目标应用独立授权尚未接入", - "未验证软件版本、配置管理关系及实际调用路径", + msg("plan.write_adapter"), + msg("plan.application_grant"), + msg("plan.unverified_target"), ], } } diff --git a/src/provider.rs b/src/provider.rs index 742429a..4033323 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1,6 +1,7 @@ //! Read-only built-in provider presets ported from Magpie's MIT data. //! No provider configuration, credentials, or network requests are accessed. +use crate::i18n::{Locale, Message, msg}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -17,6 +18,16 @@ pub enum Kind { } impl Kind { + /// Localized category label; as_str and serialized values stay stable. + pub const fn label(self) -> Message { + msg(match self { + Self::Vendor => "kind.vendor", + Self::Relay => "kind.relay", + Self::Local => "kind.local", + Self::Remote => "kind.remote", + }) + } + pub fn as_str(self) -> &'static str { match self { Self::Vendor => "vendor", @@ -67,6 +78,12 @@ pub fn search(query: &str) -> Result { || preset.id.to_lowercase().contains(&query) || preset.name.to_lowercase().contains(&query) || preset.kind.as_str().contains(&query) + || Locale::ALL.iter().any(|locale| { + locale + .text(preset.kind.label().key()) + .to_lowercase() + .contains(&query) + }) }) .collect(); Ok(Snapshot { diff --git a/src/restore.rs b/src/restore.rs index 09065e6..54b3ac7 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::msg; 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("{}", msg("restore.conflict"))] Conflict, /// Object/array replacement would have an overly broad scope. - #[error("仅支持对象中的单个标量字段;集合需要专用适配器")] + #[error("{}", msg("restore.unsupported_shape"))] UnsupportedShape, /// Invalid JSON pointer or missing parent. - #[error("无法定位恢复字段的父对象")] + #[error("{}", msg("restore.invalid_pointer"))] InvalidPointer, /// A malformed or overlapping journal must not reorder writes. - #[error("变更记录包含重复字段")] + #[error("{}", msg("restore.duplicate_field"))] DuplicateField, } diff --git a/tests/i18n.rs b/tests/i18n.rs new file mode 100644 index 0000000..c1ffc02 --- /dev/null +++ b/tests/i18n.rs @@ -0,0 +1,277 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use lmm::i18n::Locale; +use serde_json::Value; +use std::{ + ffi::OsString, + process::{Command, Output, Stdio}, +}; + +fn run(args: &[&str], variables: &[(&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("LOCALAPPDATA", home.path()) + .env("XDG_STATE_HOME", home.path()) + .env("PATH", "") + .env("NO_COLOR", "1") + .env("TERM", "dumb") + .stdin(Stdio::null()); + for key in ["LMM_LANG", "LC_ALL", "LC_MESSAGES", "LANG"] { + command.env_remove(key); + } + command.envs(variables.iter().copied()); + command.output().unwrap() +} + +fn stdout(output: &Output) -> &str { + std::str::from_utf8(&output.stdout).unwrap() +} + +fn detect(args: &[&str], variables: &[(&str, &str)]) -> Locale { + let args = args + .iter() + .map(|value| OsString::from(*value)) + .collect::>(); + Locale::detect(&args, |key| { + variables + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| OsString::from(*value)) + }) +} + +#[test] +fn recognizes_language_tags_without_confusing_traditional_chinese_with_simplified() { + for name in ["en", "EN-us", "en_GB.UTF-8", "C", "C.UTF-8", "POSIX"] { + assert_eq!(Locale::from_name(name), Some(Locale::En), "{name}"); + } + for name in [ + "zh", + "zh-CN", + "zh_CN.UTF-8", + "zh_SG", + "zh-Hans", + "zh-Hans-CN", + "zh_CN@variant", + ] { + assert_eq!(Locale::from_name(name), Some(Locale::ZhCn), "{name}"); + } + for name in ["", "fr_FR.UTF-8", "zh-TW", "zh-Hant", "pirate"] { + assert_eq!(Locale::from_name(name), None, "{name}"); + } +} + +#[test] +fn language_precedence_and_fallback_are_deterministic() { + assert_eq!( + detect( + &["lmm", "--lang", "en", "catalog"], + &[("LMM_LANG", "zh-CN")] + ), + Locale::En + ); + assert_eq!( + detect(&["lmm", "catalog", "--lang=zh-CN"], &[("LC_ALL", "C")]), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LMM_LANG", "zh-CN"), ("LC_ALL", "C")]), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LC_ALL", "C"), ("LANG", "zh-CN")]), + Locale::En + ); + assert_eq!( + detect( + &["lmm"], + &[("LC_MESSAGES", "zh_CN.UTF-8"), ("LANG", "en_US")] + ), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LMM_LANG", ""), ("LANG", "zh_CN.UTF-8")]), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LC_ALL", "fr_FR.UTF-8"), ("LANG", "zh-CN")]), + Locale::En + ); + assert_eq!(detect(&["lmm"], &[]), Locale::En); +} + +#[test] +fn positional_data_after_double_dash_does_not_select_a_language() { + assert_eq!( + detect(&["lmm", "presets", "--", "--lang=zh-CN"], &[]), + Locale::En + ); + let output = run(&["presets", "--", "--lang=zh-CN"], &[]); + assert!(output.status.success()); + assert!(stdout(&output).contains("Built-in provider presets")); +} + +#[cfg(unix)] +#[test] +fn non_unicode_environment_does_not_panic_or_override_a_cli_choice() { + use std::os::unix::ffi::OsStringExt; + let args = vec![OsString::from("lmm")]; + assert_eq!( + Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), + Locale::En + ); + let args = vec![OsString::from("lmm"), OsString::from("--lang=zh-CN")]; + assert_eq!( + Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), + Locale::ZhCn + ); +} + +#[test] +fn root_and_subcommand_help_use_the_selected_language() { + for (language, heading) in [("en", "Usage:"), ("zh-CN", "用法:")] { + let root = run(&["--lang", language, "--help"], &[]); + assert!(root.status.success(), "{:?}", root.stderr); + assert!(stdout(&root).contains(heading)); + assert!(stdout(&root).contains("--lang")); + for name in [ + "presets", + "catalog", + "setup", + "install", + "login", + "logout", + "models", + "status", + "sync", + "update", + "doctor", + "restore", + "disconnect", + "uninstall", + "run", + ] { + let output = run(&[name, "--lang", language, "--help"], &[]); + assert!(output.status.success(), "{name}: {:?}", output.stderr); + assert!( + stdout(&output).contains(heading), + "{name}: {}", + stdout(&output) + ); + assert!(stdout(&output).contains("--lang")); + assert!(!stdout(&output).contains("arg.")); + assert!(!stdout(&output).contains("cmd.")); + } + let help = run(&["--lang", language, "help", "login"], &[]); + assert!(help.status.success(), "{:?}", help.stderr); + assert!(stdout(&help).contains(heading)); + assert!(stdout(&help).contains("--issuer")); + } +} + +#[test] +fn environment_selection_and_global_flag_work_for_real_commands() { + let chinese = run(&["catalog", "astrbot"], &[("LANG", "zh_CN.UTF-8")]); + assert!(stdout(&chinese).contains("消息平台")); + let english = run( + &["catalog", "astrbot", "--lang=en-US"], + &[("LMM_LANG", "zh-CN")], + ); + assert!(stdout(&english).contains("messaging platforms")); + assert!(!stdout(&english).contains("消息平台")); + let chinese = run( + &["--lang=zh_CN.UTF-8", "catalog", "astrbot"], + &[("LC_ALL", "C")], + ); + assert!(stdout(&chinese).contains("消息平台")); +} + +#[test] +fn invalid_explicit_language_fails_without_running_a_command() { + for args in [ + vec!["--lang", "pirate", "catalog"], + vec!["catalog", "--lang"], + vec!["--lang=", "catalog"], + ] { + let output = run(&args, &[]); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn runtime_errors_and_blocked_plans_are_translated() { + let english = run(&["--lang=en", "status", "unknown-software"], &[]); + let chinese = run(&["--lang=zh-CN", "status", "unknown-software"], &[]); + assert_eq!(english.status.code(), Some(2)); + assert_eq!(chinese.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&english.stderr).contains("Unknown software ID")); + assert!(String::from_utf8_lossy(&chinese.stderr).contains("未知软件 ID")); + let chinese = run(&["setup", "astrbot", "--dry-run", "--lang=zh-CN"], &[]); + assert_eq!(chinese.status.code(), Some(3)); + assert!(stdout(&chinese).contains("计划已阻止")); + assert!(stdout(&chinese).contains("写入适配器")); + let chinese = run( + &["models", "--issuer", "http://127.0.0.1", "--lang=zh-CN"], + &[], + ); + assert_eq!(chinese.status.code(), Some(3)); + assert!(String::from_utf8_lossy(&chinese.stderr).contains("HTTPS 域名")); +} + +#[test] +fn json_messages_and_codes_do_not_change_with_the_display_language() { + for arguments in [ + vec!["catalog", "astrbot"], + vec!["presets", "local"], + vec!["status", "astrbot"], + vec!["doctor", "astrbot", "--report"], + vec!["setup", "astrbot", "--dry-run"], + vec!["status", "unknown-software"], + vec!["models", "--issuer", "http://127.0.0.1"], + vec!["login", "--non-interactive"], + ] { + let mut results = Vec::new(); + for language in ["en", "zh-CN"] { + let mut args = vec!["--json", "--lang", language]; + args.extend_from_slice(&arguments); + let output = run(&args, &[]); + assert!(output.stderr.is_empty(), "{args:?}: {:?}", output.stderr); + let mut value: Value = serde_json::from_slice(&output.stdout).unwrap(); + if let Some(object) = value.as_object_mut() { + object.remove("checked_at"); + } + results.push((output.status.code(), value)); + } + assert_eq!(results[0], results[1], "{arguments:?}"); + } + let output = run( + &["status", "unknown-software", "--lang=zh-CN", "--json"], + &[], + ); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["error_code"], "error.unknown_software"); + assert!( + value["error"] + .as_str() + .unwrap() + .starts_with("Unknown software ID") + ); +} + +#[test] +fn search_matches_translated_descriptions_without_changing_identifiers() { + for query in ["messaging platforms", " 消息平台 "] { + let output = run(&["catalog", query, "--json", "--lang=en"], &[]); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value.as_array().unwrap().len(), 1); + assert_eq!(value[0]["id"], "astrbot"); + } + let local = run(&["presets", "local", "--json"], &[]); + let translated = run(&["presets", "本地", "--json"], &[]); + assert_eq!(local.stdout, translated.stdout); +}