diff --git a/docs/README.skills.md b/docs/README.skills.md
index 24473fa6f4..085cae4686 100644
--- a/docs/README.skills.md
+++ b/docs/README.skills.md
@@ -257,6 +257,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to
| [latchshot-page-capture](../skills/latchshot-page-capture/SKILL.md)
`gh skills install github/awesome-copilot latchshot-page-capture` | Use this skill when a user needs a screenshot, website thumbnail, full-page capture, or PDF of a public HTTP(S) webpage saved as a local artifact through Latchshot, including report, QA, archive, and social-preview workflows. Do not use it for private or authenticated pages, raw HTML, scraping or extraction, arbitrary browser actions, CAPTCHA or anti-bot bypass, or local-file capture. | `scripts/latchshot.mjs` |
| [legacy-circuit-mockups](../skills/legacy-circuit-mockups/SKILL.md)
`gh skills install github/awesome-copilot legacy-circuit-mockups` | Generate breadboard circuit mockups and visual diagrams using HTML5 Canvas drawing techniques. Use when asked to create circuit layouts, visualize electronic component placements, draw breadboard diagrams, mockup 6502 builds, generate retro computer schematics, or design vintage electronics projects. Supports 555 timers, W65C02S microprocessors, 28C256 EEPROMs, W65C22 VIA chips, 7400-series logic gates, LEDs, resistors, capacitors, switches, buttons, crystals, and wires. | `references/28256-eeprom.md`
`references/555.md`
`references/6502.md`
`references/6522.md`
`references/6C62256.md`
`references/7400-series.md`
`references/assembly-compiler.md`
`references/assembly-language.md`
`references/basic-electronic-components.md`
`references/breadboard.md`
`references/common-breadboard-components.md`
`references/connecting-electronic-components.md`
`references/emulator-28256-eeprom.md`
`references/emulator-6502.md`
`references/emulator-6522.md`
`references/emulator-6C62256.md`
`references/emulator-lcd.md`
`references/lcd.md`
`references/minipro.md`
`references/t48eeprom-programmer.md` |
| [linkedin-post-formatter](../skills/linkedin-post-formatter/SKILL.md)
`gh skills install github/awesome-copilot linkedin-post-formatter` | Format and draft compelling LinkedIn posts using Unicode bold/italic styling, visual separators, structured sections, and engagement-optimized patterns. USE FOR: draft LinkedIn post, format text for LinkedIn, create social media post, write thought leadership post, convert content to LinkedIn format, LinkedIn carousel text, Unicode bold italic formatting. | `references/unicode-charmap.md` |
+| [llmfill](../skills/llmfill/SKILL.md)
`gh skills install github/awesome-copilot llmfill` | AI Word form filling — auto-fill any .docx form, questionnaire, survey, or template using reference documents, a knowledge base, or web search. Complete DDQs, due diligence questionnaires, compliance forms, applications, and checklists automatically. Built for document automation and batch form completion. | `examples/build-kb.md`
`examples/fill-a-form.md`
`references/api-account.md`
`references/api-documents.md`
`references/api-knowledge.md`
`references/curl-guide.md`
`references/errors.md`
`scripts/api_client.py`
`scripts/config.py`
`scripts/llmfill.py`
`tests/test_llmfill.py` |
| [lsp-setup](../skills/lsp-setup/SKILL.md)
`gh skills install github/awesome-copilot lsp-setup` | Enable code intelligence (go-to-definition, find-references, hover, type info) for any programming language by installing and configuring an LSP server for Copilot CLI. Detects the OS, installs the right server, and generates the JSON configuration (user-level or repo-level). Use when you need deeper code understanding and no LSP server is configured, or when the user asks to set up, install, or configure an LSP server. | `references/lsp-servers.md` |
| [make-repo-contribution](../skills/make-repo-contribution/SKILL.md)
`gh skills install github/awesome-copilot make-repo-contribution` | All changes to code must follow the guidance documented in the repository. Before any issue is filed, branch is made, commits generated, or pull request (or PR) created, a search must be done to ensure the right steps are followed. Whenever asked to create an issue, commit messages, to push code, or create a PR, use this skill so everything is done correctly. | `assets/issue-template.md`
`assets/pr-template.md` |
| [markdown-to-html](../skills/markdown-to-html/SKILL.md)
`gh skills install github/awesome-copilot markdown-to-html` | Convert Markdown files to HTML similar to `marked.js`, `pandoc`, `gomarkdown/markdown`, or similar tools; or writing custom script to convert markdown to html and/or working on web template systems like `jekyll/jekyll`, `gohugoio/hugo`, or similar web templating systems that utilize markdown documents, converting them to html. Use when asked to "convert markdown to html", "transform md to html", "render markdown", "generate html from markdown", or when working with .md files and/or web a templating system that converts markdown to HTML output. Supports CLI and Node.js workflows with GFM, CommonMark, and standard Markdown flavors. | `references/basic-markdown-to-html.md`
`references/basic-markdown.md`
`references/code-blocks-to-html.md`
`references/code-blocks.md`
`references/collapsed-sections-to-html.md`
`references/collapsed-sections.md`
`references/gomarkdown.md`
`references/hugo.md`
`references/jekyll.md`
`references/marked.md`
`references/pandoc.md`
`references/tables-to-html.md`
`references/tables.md`
`references/writing-mathematical-expressions-to-html.md`
`references/writing-mathematical-expressions.md` |
diff --git a/skills/llmfill/SKILL.md b/skills/llmfill/SKILL.md
new file mode 100644
index 0000000000..d8a69fce21
--- /dev/null
+++ b/skills/llmfill/SKILL.md
@@ -0,0 +1,191 @@
+---
+name: llmfill
+description: |
+ AI Word form filling — auto-fill any .docx form, questionnaire, survey, or template using reference documents, a knowledge base, or web search. Complete DDQs, due diligence questionnaires, compliance forms, applications, and checklists automatically. Built for document automation and batch form completion.
+# 机器可读权限声明(最小权限范围,工具/平台可据此做准入校验)
+permissions:
+ network:
+ hosts: [ "https://www.llmfill.com", "https://llmfill.com" ] # 唯一默认端点;自定义 origin 需用户显式批准
+ purpose: 上传待填文档/知识库资料,下载填写结果
+ file_read:
+ paths: [ "用户指定的 .docx 表单与参考文档", "~/.llmfill/config.json" ]
+ file_write:
+ paths: [ "用户指定输出目录(填写结果 .docx)", "~/.llmfill/(配置与缓存)" ]
+ env:
+ vars: [ "LLMFILL_BASE_URL", "LLMFILL_API_KEY", "LLMFILL_ALLOW_INSECURE_HTTP", "LLMFILL_API_TOKEN" ]
+ shell:
+ commands: [ "python scripts/llmfill.py" ] # 仅此一个入口脚本,纯标准库
+---
+
+# LLMFill Skill
+
+## 何时使用
+
+当用户需要 **AI 自动填写 Word 文档 (.docx) 中的表单、问卷、清单、模板**,
+且答案需要从参考资料、知识库或联网搜索中提取时,使用本 skill。
+
+**触发场景(出现以下说法即适用):**
+
+中文:
+
+- "帮我填一下这个 Word 文档 / 把这个 docx 填了"
+- "根据这份资料填写模板 / 照着 A 文档填 B 文档"
+- "尽职调查问卷 / DDQ 填写"
+- "合规问卷 / 调查表 / 申请表 自动填写"
+- "批量填表 / 自动作答 / AI 填表"
+- "建个知识库 / 把这些资料上传给 AI 用"
+- "用知识库回答问题 / 基于资料答题"
+
+English:
+
+- "Fill out this Word form / questionnaire / checklist"
+- "Auto-fill this docx template using reference documents"
+- "Complete a DDQ / due diligence questionnaire"
+- "Fill forms from a knowledge base"
+- "AI form filling / document auto-completion"
+- "Batch fill multiple Word documents"
+- "Extract answers from PDFs and fill Word forms"
+- "Build a knowledge base for answering forms"
+
+**不适用场景:**
+
+- 简单的 `{{占位符}}` 文本替换
+- 纯本地处理、不允许文档上传到第三方服务的场景
+
+**核心命令映射:**
+
+- 填写文档 -> `fill`
+- 建知识库 -> `kb create` + `kb upload`
+- 查余额/令牌状态 -> `whoami`
+- 接口发现 -> `discover`
+
+## 安全与数据处理
+
+**必读:本 skill 会将用户文档上传至 LLMFill 远程服务 (llmfill.com)。**
+
+**权限边界(最小化声明)**——本 skill 仅需要以下能力,不应越界:
+
+- **网络**:仅访问 `https://www.llmfill.com`(自定义端点须显式批准,见下)
+- **文件读**:仅用户指定的 .docx / 上传文件路径
+- **文件写**:仅 `~/.llmfill/`(配置/缓存)与结果下载目录
+- **环境变量**:`LLMFILL_API_KEY` / `LLMFILL_BASE_URL` / `LLMFILL_ALLOW_INSECURE_HTTP`
+ / `LLMFILL_API_TOKEN`(`--token-env` 仅接受这两个令牌变量名,不读取其它变量)
+- **Shell**:仅运行本目录 `scripts/llmfill.py`(Python 3.10+ 标准库,零第三方依赖)
+
+- **数据上传提示**:执行 `fill`、`kb upload` 等上传命令前,如文档包含机密、受监管或内部敏感信息,必须先确认用户同意将该文档发送到
+ llmfill.com 服务器处理。
+- **API 凭证管理**:API 令牌(`aif_` 开头)为长效凭证,必须安全存储。
+ - 令牌落于 `~/.llmfill/config.json`(权限 600),由**用户本人**配置,agent 全程不接触令牌。
+ - 令牌泄露时立即到 https://www.llmfill.com/profile 删除并重建
+- **服务端点**:默认锁定 `https://www.llmfill.com`。改用自定义地址(自建/代理)须
+ `config --base --allow-custom`(或交互式确认)显式批准,批准绑定到精确
+ origin 并持久化;必须为 HTTPS(本地明文 HTTP 测试需设 `LLMFILL_ALLOW_INSECURE_HTTP=1`)。
+- **知识库删除**:执行 `kb rm` 前确认目标知识库 ID,删除不可恢复;命令内置确认(交互环境需输入 y,非交互环境必须带 `--yes`)。
+- **结果校验**:AI 生成的填写内容可能有误差,正式使用前请人工复核。
+
+## 首次配置(必须)
+
+**第一步:确认运行通道**
+
+```bash
+python --version 2>/dev/null || python3 --version 2>/dev/null
+```
+
+- **有 Python(3.10+)**-> 用下方 CLI 命令(推荐:自动轮询/下载/错误解析)
+- **无 Python** -> 用 curl 直接调 API,完整操作手册见 `references/curl-guide.md`
+ (curl 在 Windows 10+/Linux/macOS 均自带,功能完全等价)
+
+**第二步:配置 API Key**
+
+> **agent 场景(默认运行方式)——不要碰令牌**:让**用户本人**在终端跑一次
+> `python scripts/llmfill.py config`(getpass 交互,令牌不进聊天/日志),或直接
+> 把令牌写进 `~/.llmfill/config.json` 的 `api_key` 字段。配置完后续所有命令
+> (fill / kb / whoami)自动读该文件,agent 全程无需接触令牌。
+
+仅当你在自己的 shell / CI 里、令牌已作为**真实环境变量**存在时:
+
+```bash
+python scripts/llmfill.py config --token-env LLMFILL_API_TOKEN
+```
+
+(`--token-env` 读的是真实环境变量,避免令牌出现在命令行历史;agent 平台的
+secrets 不会注入本地脚本,故 agent 场景请走上面的「用户本人配置」,勿用此命令。)
+
+令牌获取:引导用户去 https://www.llmfill.com/profile 注册登录 ->「API 密钥」
+-> 创建令牌 -> 复制 `aif_` 开头字符串(忘记可随时点"查看令牌"再次获取,令牌永久不变)。
+
+配置存于 `~/.llmfill/config.json`(权限 600)。**服务地址默认内置
+`https://www.llmfill.com`,无需输入**;自建/代理部署改用 `--base` 指定自定义
+地址,并加 `--allow-custom` 显式批准(或交互式确认),批准结果持久化到
+`approved_origins`。直接编辑 `base_url` 字段、或只设 `LLMFILL_BASE_URL` 不会
+生效(fail-closed,防令牌/文档被静默发往第三方)。本地明文 HTTP 测试需设
+`LLMFILL_ALLOW_INSECURE_HTTP=1`。
+
+注意:注册新用户(邮箱验证)必须在网页完成,无法通过本 skill 注册。
+
+## 核心命令
+
+所有命令加 `--json` 可获得机器可读输出(推荐 agent 使用)。
+
+```bash
+# 智能填写:上传 -> 自动轮询 -> 下载结果到模板所在目录
+python scripts/llmfill.py fill 表单.docx
+python scripts/llmfill.py fill 尽调问卷.docx --kb kb-xxx --source hybrid
+python scripts/llmfill.py fill a.docx b.docx --clean # 只下纯净版(默认两个版本都下)
+python scripts/llmfill.py fill-status b_xxx # 查批次进度
+python scripts/llmfill.py fill-download b_xxx --clean --out ./结果/
+
+# 知识库
+python scripts/llmfill.py kb ls # 列出
+python scripts/llmfill.py kb create --name "公司资料" --desc "..."
+python scripts/llmfill.py kb upload kb-xxx 资料.pdf 手册.docx # 入库(自动轮询)
+python scripts/llmfill.py kb docs kb-xxx # 列文档(含入库状态)
+python scripts/llmfill.py kb rm kb-xxx --yes # 删除(不可恢复,非交互须 --yes)
+
+# 账号
+python scripts/llmfill.py whoami # 令牌自检 + 余额
+python scripts/llmfill.py discover # 接口自动发现
+```
+
+## 关键语义
+
+- **计费**(按 token 用量):费用知情的三个节点--
+ ① **提交后**:fill 立即打印「预估费用约 X 元(实际以处理结果为准)」,
+ 取 upload 响应的 `cost_estimate`,与后端入队前校验同口径;
+ ② **上传时**:余额不足 llm-office 入队前直接拒单(402 `INSUFFICIENT_QUOTA`),
+ message 含本次预估费用与充值指引;
+ ③ **完成时**:实扣金额从 status 的 `tasks[].cost` 提取,fill 返回
+ `actual_cost`,**向用户汇报以它为准**。不会白跑算力。
+- **答案来源** `--source`:`knowledge_base`(仅知识库)/ `internet`(联网)/
+ `hybrid`(混合,默认)/ `llm_only`(纯大模型,忽略 kb)。
+- **异步与完成通知**:fill 与 kb upload 是异步任务,命令内部自动轮询(约
+ 30 分钟超时),**无需用户/agent 手动建轮询 automation**——fill 单条命令
+ 完成「上传 → 轮询 → 下载 → 主动汇报实际费用与结果绝对路径」,完成即报告。
+ kb upload 的入库进度由文档列表 `parse_status` 派生(kb docs 可查)。
+- **阶段进度**:处理分 5 阶段 `upload → generate_questions → retrieve_questions
+ → fill_answers → finalize`,其中 `retrieve_questions`(检索知识库)最耗时,
+ 进度可能长时间停在 60% 附近——这是正常的检索等待,不是卡死。fill 会在进度
+ 后附带「阶段:检索知识库」提示(服务端 status 的 `tasks[].stages` 提供)。
+- **结果路径**:fill 默认下载到**第一个模板所在目录**(`--out` 覆盖),并**同时
+ 下载标注版(`_processed.docx`)与纯净版(`_clean.docx`)两个文件**,完成时打印
+ 两个绝对路径;`--clean` 只下纯净版。
+- **认证**:所有请求带 `X-Auth-Token: aif_xxx` 头(不是 Authorization: Bearer)。
+
+## 排错
+
+- `INVALID_TOKEN`:令牌无效/已删除 -> 重新 `config`(网页上删除令牌会使其立即失效;令牌永久有效、可反复查看,不存在"重置")
+- `NETWORK_ERROR`:base URL 不通或服务维护
+- `QUOTA_EXCEEDED`:知识库数/文件数超上限(默认 10 个/库)
+- `FILE_TOO_LARGE`:单文件超 10MB
+- `METHOD_NOT_ALLOWED` / `NOT_FOUND`:接口不存在(如试图直接检索--本 skill 不提供)
+- 完整错误码表见 `references/errors.md`
+
+## 深入阅读(按需加载)
+
+- `references/curl-guide.md` - **无 Python 环境的 curl 操作指南**(等价功能)
+- `references/api-documents.md` - 文档填写接口详解
+- `references/api-knowledge.md` - 知识库接口详解
+- `references/api-account.md` - 账号/配置/whoami
+- `references/errors.md` - 统一错误码表
+- `examples/fill-a-form.md` - 端到端填写示例(含轮询细节)
+- `examples/build-kb.md` - 建库-入库示例
diff --git a/skills/llmfill/examples/build-kb.md b/skills/llmfill/examples/build-kb.md
new file mode 100644
index 0000000000..6c00cb70c9
--- /dev/null
+++ b/skills/llmfill/examples/build-kb.md
@@ -0,0 +1,50 @@
+# 示例:从零建知识库供 AI 填写使用
+
+场景:用户说"把这几份产品手册传上去,之后填表时让 AI 从里面找答案"。
+
+> ⚠️ **隐私提示**:`kb upload` 会把资料文件上传到 `llmfill.com` 存储并用于
+> 后续检索。上传前请确认有权外发这些内容(产品手册等可能含内部信息);机密、
+> 个人数据、受监管文档不要直接上传,先脱敏或征得授权。
+
+## 1. 建库
+
+```bash
+$ python scripts/llmfill.py kb create --name "产品资料" --desc "产品手册与政策文档"
+已创建知识库 kb-7c3e(产品资料)
+```
+
+## 2. 上传资料(自动轮询入库状态)
+
+```bash
+$ python scripts/llmfill.py kb upload kb-7c3e 手册A.pdf 手册B.md
+手册A.pdf:已提交,轮询入库状态…
+手册A.pdf:入库完成(可检索)
+手册B.md:已提交,轮询入库状态…
+手册B.md:入库完成(可检索)
+```
+
+入库进度由文档列表 parse_status 派生(服务端异步解析、分块、向量化)。
+单文件 ≤10MB,每库默认 10 个文件(`QUOTA_EXCEEDED` 时先删旧)。
+
+## 3. 确认入库状态
+
+```bash
+$ python scripts/llmfill.py kb docs kb-7c3e
+知识库 kb-7c3e 共 2 个文档:
+ a1b2c3d4e5f6… 手册A.pdf(completed)
+ 9f8e7d6c5b4a… 手册B.md(completed)
+```
+
+`parsing` = 还在解析;`failed` = 解析失败,删除后重传。
+
+## 4. 填表时挂载知识库
+
+```bash
+$ python scripts/llmfill.py fill 退货申请表.docx --kb kb-7c3e --source knowledge_base
+费用预估:0.3 元(后端按页计费,实际以处理结果为准)
+已提交批次 b_9f2a(1 个文件),开始轮询处理进度…
+处理完成(completed),结果已下载:./退货申请表_processed.docx、./退货申请表_clean.docx
+```
+
+`--source knowledge_base` 强制只从知识库取答案(可溯源);`hybrid`
+会联网补充;`llm_only` 忽略知识库纯用大模型。
diff --git a/skills/llmfill/examples/fill-a-form.md b/skills/llmfill/examples/fill-a-form.md
new file mode 100644
index 0000000000..de2d27fc52
--- /dev/null
+++ b/skills/llmfill/examples/fill-a-form.md
@@ -0,0 +1,61 @@
+# 示例:端到端填写一份 Word 表单
+
+场景:用户说"帮我用 AI 填一下这份《供应商尽调问卷.docx》,答案尽量从我们的
+知识库里找"。
+
+> ⚠️ **隐私提示**:`fill` 会把 Word 文档内容 + 挂载的知识库内容上传到
+> `llmfill.com` 远程处理。上传前请确认有权外发这些内容;涉及机密、个人数据、
+> 受监管或内部文档时,先征得授权或先脱敏,不要直接上传。
+
+## 1. 确认配置(首次)
+
+```bash
+$ python scripts/llmfill.py config
+ 粘贴 aif_ 开头的令牌: aif_xxx…
+配置完成并验证通过:账号 j***@gmail.com,user_id=u-123,余额 8.8
+```
+
+## 2. (可选)先看有哪些知识库可用
+
+```bash
+$ python scripts/llmfill.py kb ls --json
+{"total": 1, "kbs": [{"kb_id": "kb-2026", "name": "公司资质资料", "document_count": 6}]}
+```
+
+## 3. 填写
+
+```bash
+$ python scripts/llmfill.py fill 供应商尽调问卷.docx --kb kb-2026 --source hybrid
+已提交批次 b_9f2a(1 个文件),预估费用约 0.3 元(实际以处理结果为准),开始轮询处理进度…
+ 进度 35%(0/1 完成)
+ 进度 80%(0/1 完成),阶段:填写答案
+处理完成(completed),实际费用 1.28 元,结果已下载:/path/to/供应商尽调问卷_processed.docx、/path/to/供应商尽调问卷_clean.docx
+```
+
+费用三个节点:提交后即打印预估费用(参考);余额不足时上传即拒(402,message
+含预估费用与充值地址);完成后 fill 返回 `actual_cost`(实扣金额),汇报以此为准。
+
+- 默认同时下载标注版(`_processed.docx`)与纯净版(`_clean.docx`);只要纯净版加 `--clean`
+- 一次多份:`fill a.docx b.docx c.docx`(结果打包 zip)
+- 不自动下载:`--no-download`,之后 `fill-download b_9f2a`
+
+## 4. 处理中断了?
+
+命令轮询 30 分钟超时后退出,批次仍在服务端继续处理:
+
+```bash
+$ python scripts/llmfill.py fill-status b_9f2a
+批次 b_9f2a:processing(进度 60%,完成 0/1,失败 0)
+
+$ python scripts/llmfill.py fill-download b_9f2a --out ./结果/
+已下载:./结果/供应商尽调问卷_processed.docx、./结果/供应商尽调问卷_clean.docx
+```
+
+## 5. 处理完的批次不想要了
+
+```bash
+# 注意:删除会同时删掉服务端结果文件,下载要趁早
+$ python scripts/llmfill.py fill-status b_9f2a # 确认 completed/failed/partial(进行中不可删)
+# 批次删除无专用子命令时,用 HTTP:
+# DELETE /v1/documents/batches/b_9f2a
+```
diff --git a/skills/llmfill/references/api-account.md b/skills/llmfill/references/api-account.md
new file mode 100644
index 0000000000..aa98522871
--- /dev/null
+++ b/skills/llmfill/references/api-account.md
@@ -0,0 +1,72 @@
+# 账号与配置
+
+## API Key 获取(唯一方式:网页界面)
+
+1. 访问 https://www.llmfill.com 注册并登录(注册需邮箱验证,必须在浏览器完成,
+ 无法通过 API/skill 注册)。
+2. 进入「个人中心 -> API 密钥」,点击"创建令牌"。
+3. 复制弹窗中的 `aif_` 开头明文。
+4. 令牌永久有效,忘记明文可随时在列表中点"查看令牌"(眼睛图标)再次获取。
+
+令牌特性:
+- 明文可反复查看(服务端可逆加密存储),无需重发
+- 无过期时间,可随时删除
+- 删除后立即失效
+
+> ⚠️ **令牌是高价值长效明文凭据**:永久有效、可反复查看,意味着一旦泄露
+> (本地文件、备份、截图、日志、shell 历史),攻击者可长期使用直到你手动删除。
+> 建议:不要写进 shell 历史/截图/代码仓库/备份;`~/.llmfill/config.json` 保持
+> 600 权限;多账号时用脱敏邮箱确认归属;泄露后立即到个人中心删除重建。
+
+## 本地配置
+
+`~/.llmfill/config.json`(权限 600;`llmfill config` 只问 API Key,
+服务地址默认内置,需要改时直接编辑此文件):
+
+```json
+{
+ "base_url": "https://www.llmfill.com",
+ "api_key": "aif_xxxxxxxxxxxxxxxx",
+ "user_id": "u-xxxx"
+}
+```
+
+自建/代理部署时用 `config --base --allow-custom` 显式批准自定义地址(批准
+持久化到 `approved_origins`,绑定精确 origin)。直接改 `base_url` 字段、或只设
+`LLMFILL_BASE_URL` 不会生效(fail-closed,防令牌被静默发往第三方)。
+环境变量 `LLMFILL_API_KEY` / `LLMFILL_BASE_URL` 优先于配置文件(CI 友好;
+`LLMFILL_BASE_URL` 须指向已批准的 origin)。
+
+无 Python 环境可手工创建此文件(curl 通道同样读取),见 `curl-guide.md`。
+
+## GET /v1/account/whoami
+
+请求头 `X-Auth-Token`。响应:
+
+```json
+{
+ "user_id": "u-xxx",
+ "token_valid": true,
+ "token_name": "生产后端",
+ "email_masked": "j***@gmail.com",
+ "balance": 8.8,
+ "total_recharged": 10.0,
+ "total_spent": 1.2
+}
+```
+
+`token_name` / `email_masked` 为身份回显字段,帮助用户确认令牌归属
+(脱敏邮箱,多账号用户可据此发现粘错令牌)。
+
+balance 为 null 时表示网关未配置内部密钥(余额暂不可查),令牌自检仍有效。
+
+## GET /v1/openapi
+
+三服务聚合的 OpenAPI schema(无认证,per-IP 限流 10/min,缓存 1h)。
+`llmfill discover` 命令解析并缓存到 `~/.llmfill/endpoints.json`,
+agent 可用于运行时校验参数/发现新接口。
+
+## 充值
+
+余额不足(`INSUFFICIENT_QUOTA`)时引导用户到 https://www.llmfill.com 充值
+(微信支付),注册用户有赠送积分。
diff --git a/skills/llmfill/references/api-documents.md b/skills/llmfill/references/api-documents.md
new file mode 100644
index 0000000000..0036933d7f
--- /dev/null
+++ b/skills/llmfill/references/api-documents.md
@@ -0,0 +1,72 @@
+# 文档填写接口(llm-office)
+
+网关 base:`https://www.llmfill.com`,认证头 `X-Auth-Token: aif_xxx`。
+
+## 流程
+
+```
+POST /v1/documents/upload -> {batch_id, tasks} (余额不足 402 拒单)
+GET /v1/documents/{batch_id}/status (轮询,3-5s 间隔;tasks[].cost = 实扣)
+GET /v1/documents/{batch_id}/result[?clean=true] (下载)
+DELETE /v1/documents/batches/{batch_id} (清理)
+```
+
+> 费用链路:余额不足时上传即被拒(402,message 含预估费用与充值指引,
+> 批次不建、算力不跑);余额够则正常入队,实扣金额在完成后的 status
+> 响应 `tasks[].cost`(按真实 token 用量计费,与 auth-service 记账同值)。
+
+## POST /v1/documents/upload
+
+multipart/form-data:
+
+| 字段 | 必填 | 默认 | 说明 |
+|------|------|------|------|
+| `files` | ✅ | - | .docx 文件列表(可多个) |
+| `answer_source` | ❌ | `hybrid` | `knowledge_base` / `internet` / `hybrid` / `llm_only` |
+| `kb_ids` | ❌ | `[]` | 知识库 ID 列表的 JSON 字符串,如 `["kb-1","kb-2"]` |
+| `mode` | ❌ | `fast` | `fast` / `economy` |
+
+响应:`{batch_id, status: "pending", total_count, tasks: [...]}`
+
+- 余额不足(含透支额度)时 402 拒单:
+ `{"detail": "余额不足:本次预估费用 1.34 元,可用额度不足,请充值后再试(充值地址 https://www.llmfill.com/profile)"}`
+ (网关包装为 `INSUFFICIENT_QUOTA`,message 原样透传)
+- 实扣金额在处理完成后的 status 响应 `tasks[].cost`(按真实 token 用量
+ × 单价 × 利润倍数),以它为准向用户汇报。
+
+## GET /v1/documents/{batch_id}/status
+
+批次聚合状态(响应 `tasks[].cost` 为该任务**实扣金额**:finalize 阶段按
+真实 token 用量算出,与 auth-service 记账同值;预估见独立 `/estimate`
+端点,不在 upload 响应里):
+
+| status | 含义 |
+|--------|------|
+| `pending` | 排队中 |
+| `processing` | 处理中(`progress` 0-100) |
+| `completed` | 全部完成 |
+| `partial` | 部分成功(可下载已完成部分) |
+| `failed` | 全部失败 |
+
+任务内有 5 个阶段:`upload -> generate_questions -> retrieve_questions -> fill_answers -> finalize`,
+`stages.*.question_count` 可看生成的问题数。任务超时(2h)会在查询时标记 failed。
+
+## GET /v1/documents/{batch_id}/result
+
+- 1 个完成任务 -> 单 `.docx` 文件流
+- 多个 -> `.zip`
+- `?clean=true` -> 纯净版(去除颜色标注)
+- 文件名在 `Content-Disposition` 头
+- 批次未完成时返回 400
+
+## POST /v1/documents/estimate(独立端点)
+
+multipart 同 upload(只传 files),响应 `{total_cost, breakdown: [{filename, chars, cost}]}`。
+与后端入队前余额校验同口径。**upload 响应不含 cost_estimate**——fill 命令在
+提交前会先调用本端点拿预估并展示(best-effort,失败不阻塞提交);前端上传前的
+费用确认页也在用此端点。
+
+## 计费
+
+按文档正文字符数×单价(页计费,0.2 元/页级别)。入队前余额不足会被
+`precheck` 拒单(`INSUFFICIENT_QUOTA`)。处理完成后由 auth-service 记账扣费。
diff --git a/skills/llmfill/references/api-knowledge.md b/skills/llmfill/references/api-knowledge.md
new file mode 100644
index 0000000000..c6d8c5471e
--- /dev/null
+++ b/skills/llmfill/references/api-knowledge.md
@@ -0,0 +1,56 @@
+# 知识库接口(raglite)
+
+网关 base:`https://www.llmfill.com`,认证头 `X-Auth-Token: aif_xxx`。
+
+> 网关只暴露网页前端在用的知识库接口。**不提供检索(search)与任务查询
+> (tasks)**:检索是服务端填写链路的内部行为;入库进度由文档列表的
+> `parse_status` 派生。
+
+## 知识库管理
+
+| 方法 | 网关路径 | 说明 |
+|------|---------|------|
+| POST | `/v1/knowledge-bases` | 创建,body `{name, description?}` -> `{kb_id}` |
+| GET | `/v1/knowledge-bases` | 列出当前用户所有 KB(含 document_count) |
+| PUT | `/v1/knowledge-bases/{kb_id}` | 更新名称/描述(未传字段保持原值) |
+| DELETE | `/v1/knowledge-bases/{kb_id}` | 删除 KB 及所有数据(不可逆) |
+| GET | `/v1/knowledge-bases/{kb_id}/documents` | 列文档(含 parse_status) |
+
+配额:每用户 10 个 KB(`QUOTA_EXCEEDED`)。
+
+## 文档入库(异步)
+
+`POST /v1/knowledge-bases/{kb_id}/documents`,multipart:
+- `file`:文件(PDF/Markdown/TXT 等,单文件 ≤10MB)
+- kb_id 已在路径上,无需 form 传
+
+响应 `{task_id, status: "pending"}`。
+
+**入库进度看文档列表**(不是任务接口):轮询
+`GET /v1/knowledge-bases/{kb_id}/documents`,按文件名找到新文档,其
+`parse_status`:
+- `parsing`:解析中,继续轮询
+- `completed`:入库完成,可被填写挂载检索
+- `failed`:解析失败,可删除后重传
+
+配额:每 KB 10 个文件(`QUOTA_EXCEEDED`)。
+
+## 文件管理
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/v1/knowledge-bases/{kb_id}/files/{document_id}/download` | 下载原文件 |
+| DELETE | `/v1/knowledge-bases/{kb_id}/files/{document_id}` | 删文件及向量数据 |
+
+`document_id` 从文档列表获取。
+
+> ⚠️ **删除不可恢复**:`DELETE /v1/knowledge-bases/{kb_id}/files/{document_id}`
+> 会**同时删除原文件与已解析的全部向量/索引数据**,且无软删除、无回收站,
+> 无法恢复。删除前请确认本机留有源文件副本(必要时先用 download 接口导出);
+> CLI 侧 `llmfill kb rm` 会要求交互确认或显式 `--yes`,直接调 API 则没有这层保护。
+
+## 与填写的配合
+
+知识库建好后,`fill --kb ` 挂载给文档填写使用(服务端在
+retrieve 阶段检索知识库取答案)。`--source knowledge_base` 强制只用
+知识库答案(可溯源),`hybrid` 联网补充,`llm_only` 忽略知识库。
diff --git a/skills/llmfill/references/curl-guide.md b/skills/llmfill/references/curl-guide.md
new file mode 100644
index 0000000000..8f498c6a5a
--- /dev/null
+++ b/skills/llmfill/references/curl-guide.md
@@ -0,0 +1,121 @@
+# 无 Python 环境的 curl 操作指南
+
+> 本指南用于**本机没有 Python** 时:agent 直接用 Bash + curl 调用网关 API
+> 完成全部功能(curl 在 Windows 10+/Linux/macOS 均自带)。有 Python 时
+> 优先用 `scripts/llmfill.py`(自动轮询/下载/错误解析,体验更好)。
+
+> ⚠️ **隐私与数据外发**:以下所有操作都会把**文档内容 + 你的 API 令牌**
+> 发送到 `https://www.llmfill.com` 远程服务器处理/存储。上传前请确认你有权
+> 外发这些内容;**不要上传**机密、个人敏感数据、受监管或仅限内网的文档。
+> 令牌为明文长效凭证,请勿写进 shell 历史、截图、备份或代码仓库;泄露后立即到
+> 个人中心删除重建。
+
+> 🔒 **agent 执行门(必须遵守)**:本指南中的 curl 命令不得由 agent 未经确认
+> 直接执行。任何会**上传文件或发送令牌**的命令(POST/带 `-F`/带 `-K`)执行前,
+> 必须先向用户明确列出:① 将上传的文件路径;② 目标域名(仅限
+> `https://www.llmfill.com`);③ 该操作会外发文档内容,并取得用户同意。
+> 删除类命令(DELETE)同理需确认。仅当用户明确同意后方可执行。
+
+## 环境约定
+
+- 服务地址(BASE):`https://www.llmfill.com`(内置默认;自建部署改这里)
+- 认证:每个请求都带请求头 `X-Auth-Token: aif_xxx`(**不是** Authorization: Bearer)。
+ 本指南统一用 `-K ~/.llmfill/curl.conf` 从配置文件读该头(见第一步),令牌不出现在命令行
+- 错误格式(非 2xx 统一返回):
+ `{"success":false,"error":{"code":"...","message":"...","request_id":"req_xxx"}}`
+ 常见码:`INVALID_TOKEN`(401) `INSUFFICIENT_QUOTA`(402 余额不足,message
+ 带预估费用与充值地址) `QUOTA_EXCEEDED`(409 超配额) `FILE_TOO_LARGE`(413)
+ `METHOD_NOT_ALLOWED`/`NOT_FOUND`(接口未暴露或不存在)。
+
+## 第一步:配置令牌(写进 curl 配置文件,避免令牌进命令行)
+
+```bash
+umask 077 # 本次会话新建文件默认不对外
+install -d -m 700 ~/.llmfill # 目录仅本人可进
+cat > ~/.llmfill/curl.conf <<'EOF'
+header = "X-Auth-Token: aif_你的令牌"
+EOF
+chmod 600 ~/.llmfill/curl.conf # 明文凭据文件仅本人可读写
+```
+
+之后所有 curl 命令用 `-K ~/.llmfill/curl.conf` 注入认证头。**不要**用
+`-H "X-Auth-Token: ..."`——`-H` 参数会留在 shell 历史和进程列表里,被
+本机其它用户/进程看到。
+
+令牌获取:浏览器打开 https://www.llmfill.com 注册登录 -> 个人中心「API 密钥」
+-> 创建令牌 -> 复制 `aif_` 开头字符串(忘记可随时点"查看令牌"再次获取)。
+
+自检令牌与余额:
+
+```bash
+curl -s -K ~/.llmfill/curl.conf https://www.llmfill.com/v1/account/whoami
+# {"user_id":"u-xxx","token_valid":true,"balance":8.8,...}
+```
+
+## 核心场景 A:智能填写一份 .docx
+
+```bash
+# 1. 上传(余额不足此处直接 402,message 含预估费用与充值地址)
+curl -s -X POST https://www.llmfill.com/v1/documents/upload \
+ -K ~/.llmfill/curl.conf \
+ -F "files=@表单.docx" \
+ -F "answer_source=hybrid" \
+ -F 'kb_ids=["kb-xxx"]' # 挂载知识库时才传
+# -> {"batch_id":"b_xxx","status":"pending",...} 记下 batch_id
+
+# 2. 轮询状态(每 5 秒重复执行,直到 status 为 completed/partial/failed)
+curl -s https://www.llmfill.com/v1/documents/b_xxx/status \
+ -K ~/.llmfill/curl.conf
+# 关注:status、progress、tasks[].cost(实扣金额,完成后有值)
+
+# 3. 下载结果(status 为 completed/partial 后)
+curl -s -OJ https://www.llmfill.com/v1/documents/b_xxx/result \
+ -K ~/.llmfill/curl.conf # -OJ 按响应文件名保存
+# 纯净版(无颜色标注)加 ?clean=true
+```
+
+轮询由 agent 自己执行:`sleep 5` 后重查,通常几十秒到几分钟。
+批次处理最长 2 小时。超时后可随时回来续查(第 2 步)。
+
+## 核心场景 B:建知识库并上传资料
+
+```bash
+# 1. 建库 -> 返回 kb_id
+curl -s -X POST https://www.llmfill.com/v1/knowledge-bases \
+ -K ~/.llmfill/curl.conf -H "Content-Type: application/json" \
+ -d '{"name":"公司资料","description":"产品手册与政策"}'
+
+# 2. 上传资料(kb_id 在路径上;单文件 ≤10MB,每库 ≤10 个文件)
+curl -s -X POST https://www.llmfill.com/v1/knowledge-bases/kb-xxx/documents \
+ -K ~/.llmfill/curl.conf -F "file=@手册.pdf"
+
+# 3. 轮询入库状态(每 5 秒重查,parse_status: parsing -> completed/failed)
+curl -s https://www.llmfill.com/v1/knowledge-bases/kb-xxx/documents \
+ -K ~/.llmfill/curl.conf
+```
+
+## 常用辅助操作
+
+```bash
+# 知识库列表 / 文档列表
+curl -s https://www.llmfill.com/v1/knowledge-bases -K ~/.llmfill/curl.conf
+curl -s https://www.llmfill.com/v1/knowledge-bases/kb-xxx/documents -K ~/.llmfill/curl.conf
+
+# 批次历史列表
+curl -s https://www.llmfill.com/v1/documents -K ~/.llmfill/curl.conf
+
+# 接口自动发现(拉聚合 OpenAPI schema)
+curl -s https://www.llmfill.com/v1/openapi
+
+# 删除批次(仅 completed/failed/partial 可删;删除同时清掉服务端结果文件,下载要趁早)
+curl -s -X DELETE https://www.llmfill.com/v1/documents/batches/b_xxx -K ~/.llmfill/curl.conf
+```
+
+## 注意
+
+- **检索(/v1/search)与任务查询(/v1/tasks)不存在**:检索是服务端填写
+ 链路内部行为,知识库通过上传时的 `kb_ids` 挂载使用;入库进度看文档列表
+ 的 `parse_status`。
+- 上传用 `-F`(multipart),JSON 接口用 `-d` + `Content-Type: application/json`。
+- Windows cmd 下 curl 语法相同;多文件上传重复 `-F "files=@a.docx" -F "files=@b.docx"`。
+- 费用:上传时余额不足即拒(402);实扣金额看 status 的 `tasks[].cost`。
diff --git a/skills/llmfill/references/errors.md b/skills/llmfill/references/errors.md
new file mode 100644
index 0000000000..80540c7085
--- /dev/null
+++ b/skills/llmfill/references/errors.md
@@ -0,0 +1,39 @@
+# 统一错误码表
+
+网关对所有非 2xx 响应统一包装为:
+
+```json
+{
+ "success": false,
+ "error": {
+ "code": "INVALID_TOKEN",
+ "message": "人类可读的错误信息",
+ "request_id": "req_a1b2c3d4e5f6"
+ }
+}
+```
+
+`request_id` 可用于向服务方反馈排障。
+
+| code | HTTP | 含义 | 处理建议 |
+|------------------------|---------|--------------------------------|------------------------------------------|
+| `INVALID_TOKEN` | 401 | 令牌缺失/无效/已过期/已删除 | 重新 `config`;网页端删除令牌会使其立即失效(令牌永久有效、可反复查看) |
+| `INSUFFICIENT_QUOTA` | 402 | 余额不足(上传时入队前拒单) | message 含本次预估费用与充值地址,直接转告用户 |
+| `FORBIDDEN` | 403 | 无权访问该资源(他人批次/知识库) | 检查资源归属 |
+| `NOT_FOUND` | 404 | 批次/知识库/文档/任务不存在;或接口未暴露 | 检查 ID;本 skill 不提供检索等未暴露接口 |
+| `METHOD_NOT_ALLOWED` | 405 | 路径存在但方法不允许(如对 KB 详情用 GET) | 检查方法/改用支持的命令 |
+| `QUOTA_EXCEEDED` | 409 | 知识库数(默认 10/用户)或文件数(默认 10/库)超上限 | 删旧建新 |
+| `FILE_TOO_LARGE` | 413 | 单文件超 10MB | 拆分或压缩 |
+| `VALIDATION_ERROR` | 400/422 | 参数校验失败 | 检查字段类型/范围 |
+| `RATE_LIMITED` | 429 | 公开端点限流(openapi/health) | 按 `Retry-After` 头等待重试 |
+| `UPSTREAM_UNAVAILABLE` | 502 | 上游服务不可用/网关故障 | 稍后重试 |
+| `UPSTREAM_TIMEOUT` | 504 | 上游响应超时 | 稍后重试 |
+| `INTERNAL_ERROR` | 500 | 服务内部错误 | 带 request_id 反馈 |
+| `TASK_TIMEOUT` | -(客户端) | 轮询超时(30 分钟) | 用 `fill-status` 续查 |
+| `NETWORK_ERROR` | -(客户端) | 无法连接服务 | 检查网络/base URL |
+
+业务层错误(HTTP 200 内的失败态):
+
+- 批次状态 `partial`:部分文件失败,可下载已完成部分
+- 批次状态 `failed`:全部失败
+- 文档 `parse_status=failed`:入库解析失败,删除后重新上传
diff --git a/skills/llmfill/scripts/api_client.py b/skills/llmfill/scripts/api_client.py
new file mode 100644
index 0000000000..5e04a48223
--- /dev/null
+++ b/skills/llmfill/scripts/api_client.py
@@ -0,0 +1,303 @@
+# -*- coding: utf-8 -*-
+"""llmfill skill - 统一 HTTP 客户端(纯标准库,零第三方依赖)。
+
+职责:
+- 注入认证头 ``X-Auth-Token``(后端混合认证认的是它,不是 Authorization: Bearer);
+- 发送 JSON / multipart 请求(urllib.request);
+- 解析网关统一错误体 ``{"success":false,"error":{code,message,request_id}}``
+ (兼容旧格式 ``detail``),抛 :class:`ApiError`;
+- 二进制下载到指定目录,按 Content-Disposition 取文件名。
+"""
+
+from __future__ import annotations
+
+import json
+import mimetypes
+import os
+import shutil
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+from pathlib import Path
+from typing import Iterable
+
+from config import normalize_origin
+
+
+class ApiError(Exception):
+ """API 调用失败(非 2xx 或网络异常),携带统一错误码。"""
+
+ def __init__(self, code: str, message: str, status: int = 0) -> None:
+ super().__init__(f"[{code}] {message}")
+ self.code = code
+ self.message = message
+ self.status = status
+
+
+class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
+ """仅允许同 origin 重定向;跨域/降级一律拒绝,防 X-Auth-Token 泄露到第三方。
+
+ urllib 默认会把原请求头(含 X-Auth-Token)转发到重定向目标,跨域即泄露凭据。
+ 此处用与配置校验同一套 normalize_origin 比较,不同则抛 HTTPError 终止,
+ 绝不转发认证头。
+ """
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ old = normalize_origin(req.full_url)
+ new = normalize_origin(newurl)
+ if old != new:
+ raise urllib.error.HTTPError(
+ req.full_url, code,
+ f"拒绝跨域重定向:{old} -> {new}(不转发认证头)",
+ headers, fp,
+ )
+ # 同 origin:沿用标准行为(POST 301/302/303 会转 GET 丢弃 multipart body,
+ # 307/308 的 POST 不重定向;https->http 降级已被 origin 比较拦截)
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+# 全局 opener:用「仅同源重定向」handler 替换 urllib 默认的跨域跟随
+_opener = urllib.request.build_opener(_SameOriginRedirectHandler())
+
+
+def _headers_with_auth(api_key: str, extra: dict | None = None) -> dict:
+ headers = {"X-Auth-Token": api_key, "Accept": "application/json"}
+ if extra:
+ headers.update(extra)
+ return headers
+
+
+def _parse_error_body(raw: bytes, status: int) -> ApiError:
+ """把错误响应体转成 ApiError:优先网关统一格式,兼容旧 detail 格式。"""
+ try:
+ body = json.loads(raw.decode("utf-8", "replace"))
+ except (ValueError, UnicodeDecodeError):
+ return ApiError("HTTP_ERROR", f"HTTP {status}(响应非 JSON)", status)
+
+ if isinstance(body, dict):
+ err = body.get("error")
+ if isinstance(err, dict):
+ code = str(err.get("code") or "HTTP_ERROR")
+ msg = str(err.get("message") or f"HTTP {status}")
+ return ApiError(code, msg, status)
+ detail = body.get("detail") or body.get("message")
+ if isinstance(detail, str) and detail:
+ code = "INVALID_TOKEN" if status == 401 else "HTTP_ERROR"
+ return ApiError(code, detail, status)
+ return ApiError("HTTP_ERROR", f"HTTP {status}", status)
+
+
+def request_json(
+ base_url: str,
+ api_key: str,
+ method: str,
+ path: str,
+ *,
+ json_body: dict | None = None,
+ form_fields: dict[str, str] | None = None,
+ files: list[tuple[str, str, bytes]] | None = None,
+ timeout: float = 60.0,
+) -> dict:
+ """发请求并返回 JSON dict。
+
+ json_body:JSON 请求体;form_fields + files:multipart 表单(上传用)。
+ 两者互斥,都传时以 multipart 为准。
+ """
+ url = f"{base_url.rstrip('/')}{path}"
+
+ headers = _headers_with_auth(api_key)
+ body: bytes | None = None
+
+ if files is not None:
+ boundary = f"----llmfill-{uuid.uuid4().hex}"
+ body = _encode_multipart(boundary, form_fields or {}, files)
+ headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
+ elif json_body is not None:
+ body = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+ elif form_fields is not None:
+ body = urllib.parse.urlencode(form_fields).encode("utf-8")
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
+
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
+ try:
+ with _opener.open(req, timeout=timeout) as resp:
+ raw = resp.read()
+ if not raw:
+ return {}
+ return json.loads(raw.decode("utf-8", "replace"))
+ except urllib.error.HTTPError as exc:
+ raise _parse_error_body(exc.read(), exc.code) from exc
+ except urllib.error.URLError as exc:
+ reason = getattr(exc, "reason", exc)
+ raise ApiError("NETWORK_ERROR", f"无法连接 {base_url}:{reason}") from exc
+ except TimeoutError as exc:
+ raise ApiError("TIMEOUT", f"请求超时({timeout}s):{url}") from exc
+ except ValueError as exc:
+ raise ApiError("PARSE_ERROR", f"响应不是合法 JSON:{exc}") from exc
+
+
+def _encode_multipart(
+ boundary: str,
+ fields: dict[str, str],
+ files: list[tuple[str, str, bytes]],
+) -> bytes:
+ """手工构造 multipart/form-data 请求体。
+
+ files 元组:(字段名, 文件名, 内容)。content-type 按文件名推断。
+
+ 文件名策略:
+ - ``filename=`` 直接使用原始文件名(UTF-8 字节)。虽然 RFC 7578 之前的
+ 规范要求 header 为 ASCII,但现代 HTTP 服务器普遍支持 UTF-8;
+ 若接收方(如 Starlette)按 latin-1 解码产生乱码,服务端的
+ ``_repair_upload_filename`` 会通过 latin-1 -> utf-8 还原。
+ - 同时携带 ``filename*=UTF-8''...``(RFC 5987)作为兼容,供正确实现
+ 的客户端/代理使用。
+ """
+ parts: list[bytes] = []
+ for name, value in fields.items():
+ parts.append(
+ (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
+ f"{value}\r\n"
+ ).encode("utf-8")
+ )
+ for field_name, filename, content in files:
+ ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
+ # 对 filename= 中的双引号、换行等做转义,避免破坏 header 结构
+ safe_name = filename.replace('"', "'").replace("\r", " ").replace("\n", " ")
+ encoded_name = urllib.parse.quote(filename, safe="")
+ parts.append(
+ (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="{field_name}"; '
+ f'filename="{safe_name}"; '
+ f"filename*=UTF-8''{encoded_name}\r\n"
+ f"Content-Type: {ctype}\r\n\r\n"
+ ).encode("utf-8") + content + b"\r\n"
+ )
+ parts.append(f"--{boundary}--\r\n".encode("utf-8"))
+ return b"".join(parts)
+
+
+def _ascii_fallback_filename(name: str) -> str:
+ """生成 ASCII-only 的 fallback 文件名(RFC 5987 同时提供 filename* 时用)。"""
+ cleaned = name.replace('"', "'").replace("\r", " ").replace("\n", " ")
+ try:
+ cleaned.encode("ascii")
+ return cleaned
+ except UnicodeEncodeError:
+ return "file"
+
+
+def _safe_filename(name: str) -> str:
+ """把服务端返回的文件名当作不可信输入:仅取 basename,拒绝路径分隔符/盘符/.. 与控制字符。
+
+ 服务端(或中间人)可通过 Content-Disposition 控制文件名;直接 ``out_dir / name``
+ 会被 ``../../`` 或绝对路径穿越到 out_dir 之外。此处统一分隔符后取末段,再剥控制字符。
+ """
+ # 统一分隔符后取最后一段(POSIX "/" 与 Windows "\\" 均视为分隔符)
+ basename = name.replace("\\", "/").split("/")[-1].strip()
+ if basename in ("", ".", ".."):
+ return "download.bin"
+ # 去除控制字符与 DEL(避免换行/退格注入文件名)
+ cleaned = "".join(c for c in basename if ord(c) >= 32 and ord(c) != 0x7F)
+ if not cleaned:
+ return "download.bin"
+ return cleaned
+
+
+def download_file(
+ base_url: str,
+ api_key: str,
+ path: str,
+ out_dir: Path,
+ *,
+ timeout: float = 300.0,
+) -> Path:
+ """下载二进制响应到 out_dir,文件名取 Content-Disposition(无则用 URL 尾段)。"""
+ url = f"{base_url.rstrip('/')}{path}"
+ req = urllib.request.Request(
+ url, headers=_headers_with_auth(api_key), method="GET"
+ )
+ try:
+ with _opener.open(req, timeout=timeout) as resp:
+ disp = resp.headers.get("Content-Disposition") or ""
+ raw_name = _filename_from_disposition(disp) or path.rstrip("/").split("/")[-1] or "download.bin"
+ filename = _safe_filename(raw_name)
+ out_dir.mkdir(parents=True, exist_ok=True)
+ root = out_dir.resolve()
+ target = (root / filename).resolve()
+ # 二次防护:确保最终路径仍落在 out_dir 内(防御 basename 未覆盖的边界)
+ if target.parent != root:
+ raise ApiError("UNSAFE_FILENAME", f"服务端返回了不安全文件名:{raw_name!r}")
+ target = _unique_path(target)
+ # 流式写入,避免大文件一次性读入内存
+ with open(target, "wb") as f:
+ shutil.copyfileobj(resp, f)
+ return target
+ except urllib.error.HTTPError as exc:
+ raise _parse_error_body(exc.read(), exc.code) from exc
+ except urllib.error.URLError as exc:
+ reason = getattr(exc, "reason", exc)
+ raise ApiError("NETWORK_ERROR", f"无法连接 {base_url}:{reason}") from exc
+
+
+def _filename_from_disposition(disp: str) -> str | None:
+ """从 Content-Disposition 提取文件名(支持 filename*=UTF-8'' 与 filename=)。"""
+ import re
+
+ m = re.search(r"filename\*=UTF-8''([^;]+)", disp, re.IGNORECASE)
+ if m:
+ return urllib.parse.unquote(m.group(1))
+ m = re.search(r'filename="?([^";]+)"?', disp, re.IGNORECASE)
+ if m:
+ return m.group(1)
+ return None
+
+
+def _unique_path(path: Path) -> Path:
+ """目标文件已存在时加 (1) (2) 后缀,不覆盖旧文件。"""
+ if not path.exists():
+ return path
+ stem, suffix = path.stem, path.suffix
+ for i in range(1, 1000):
+ candidate = path.with_name(f"{stem}({i}){suffix}")
+ if not candidate.exists():
+ return candidate
+ return path.with_name(f"{stem}-{uuid.uuid4().hex[:6]}{suffix}")
+
+
+def poll_until_done(
+ *,
+ interval: float,
+ timeout: float,
+ poll_fn,
+ is_done=lambda state: True,
+ on_tick=None,
+) -> dict:
+ """通用轮询:poll_fn() 返回状态 dict,is_done 判断完成,超时抛 ApiError。
+
+ 轮询间隔以 interval 起步、每次 *1.5 退避(上限 15s),适配 3-5s 起步、
+ 30min 总超时的 fill/kb-upload 场景。
+ """
+ deadline = time.monotonic() + timeout
+ current = interval
+ ticks = 0
+ while True:
+ state = poll_fn()
+ if is_done(state):
+ return state
+ if time.monotonic() >= deadline:
+ raise ApiError(
+ "TASK_TIMEOUT",
+ f"任务超时(>{timeout:.0f}s)。可稍后用状态查询命令查看进度。",
+ )
+ ticks += 1
+ if on_tick:
+ on_tick(ticks, state)
+ time.sleep(current)
+ current = min(current * 1.5, 15.0)
diff --git a/skills/llmfill/scripts/config.py b/skills/llmfill/scripts/config.py
new file mode 100644
index 0000000000..d453850edf
--- /dev/null
+++ b/skills/llmfill/scripts/config.py
@@ -0,0 +1,186 @@
+# -*- coding: utf-8 -*-
+"""llmfill skill - 本地配置读写。
+
+配置存于 ``~/.llmfill/config.json``(权限 600)。
+环境变量 ``LLMFILL_BASE_URL`` / ``LLMFILL_API_KEY`` 优先级高于配置文件,
+便于 CI 或临时覆盖而不落盘。
+
+安全模型(对应审计 T09:未校验/未批准的凭据与文档外发目的地):
+- base_url 默认仅允许 ``https://www.llmfill.com``(含裸域 llmfill.com);
+- 自定义 origin(自建/代理)必须显式批准:交互确认或 ``config --allow-custom``,
+ 批准结果持久化到 ``approved_origins``,之后每次调用 fail-closed 校验;
+- 明文 HTTP 默认拒绝,仅 ``LLMFILL_ALLOW_INSECURE_HTTP=1`` 显式放行(本地开发/测试)。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import stat
+import urllib.parse
+from pathlib import Path
+
+CONFIG_DIR = Path.home() / ".llmfill"
+CONFIG_PATH = CONFIG_DIR / "config.json"
+ENDPOINTS_PATH = CONFIG_DIR / "endpoints.json"
+
+DEFAULT_BASE_URL = "https://www.llmfill.com"
+
+# 默认信任的完整 origin(scheme+host+端口,默认端口省略);其余一律视为
+# 「自建/代理」需显式批准——非默认端口即使 host 在名单内也不算默认信任
+DEFAULT_ALLOWED_ORIGINS = ("https://www.llmfill.com", "https://llmfill.com")
+
+# 显式开关:允许明文 HTTP(仅本地开发/测试),默认关闭。设 1/true/yes 生效。
+ALLOW_INSECURE_HTTP_ENV = "LLMFILL_ALLOW_INSECURE_HTTP"
+
+_VALID_KEYS = {
+ "base_url", "api_key", "user_id", "default_kb", "created_at",
+ # 用户显式批准的自定义 endpoint(精确 origin,规范化后的 scheme://host[:port])
+ "approved_origins",
+}
+
+
+class ConfigError(Exception):
+ """配置缺失或格式错误。"""
+
+
+def allow_insecure_http() -> bool:
+ """是否显式放行明文 HTTP(本地开发/测试开关,默认关闭)。"""
+ return os.environ.get(ALLOW_INSECURE_HTTP_ENV, "").strip().lower() in ("1", "true", "yes")
+
+
+def validate_base_url(url: str, *, allow_insecure: bool = False) -> str:
+ """校验并规范化 base_url,返回标准化 origin(无尾斜杠)。
+
+ 安全约束:
+ - 默认仅允许 https;明文 http 仅在 ``allow_insecure=True``(本地开发/测试)时放行;
+ - 拒绝内嵌用户名/密码、# 锚点、查询串、多余路径;
+ - 必须含主机名。
+ 返回 ``://host[:port]``,不带尾斜杠。
+ """
+ parts = urllib.parse.urlsplit(url or "")
+ scheme = parts.scheme
+ if scheme != "https" and not (allow_insecure and scheme == "http"):
+ raise ConfigError(
+ f"base_url 必须为 HTTPS(当前:{scheme or '空'})。"
+ "拒绝明文 HTTP 或其它 scheme;确需本地明文 HTTP 请设 "
+ f"{ALLOW_INSECURE_HTTP_ENV}=1。"
+ )
+ if parts.username or parts.password:
+ raise ConfigError("base_url 不允许内嵌用户名/密码")
+ if parts.fragment:
+ raise ConfigError("base_url 不允许包含 # 锚点")
+ if parts.query:
+ raise ConfigError("base_url 不允许包含查询串")
+ if parts.path not in ("", "/"):
+ raise ConfigError(
+ "base_url 应只填 origin(如 https://www.llmfill.com),不要带路径"
+ )
+ host = parts.hostname
+ if not host:
+ raise ConfigError("base_url 缺少主机名")
+ return f"{scheme}://{host}" + (f":{parts.port}" if parts.port else "")
+
+
+def normalize_origin(url: str) -> str:
+ """规范化 origin:小写 host + 去默认端口,配置校验与重定向校验共用同一规则。
+
+ ``https://www.llmfill.com:443`` 与 ``https://www.llmfill.com`` 规范化后相等;
+ 非默认端口(如 :8443)保留,代表不同 origin。
+ """
+ parts = urllib.parse.urlsplit(url or "")
+ host = (parts.hostname or "").lower()
+ port = parts.port
+ if port is None or (parts.scheme == "https" and port == 443) \
+ or (parts.scheme == "http" and port == 80):
+ return f"{parts.scheme}://{host}"
+ return f"{parts.scheme}://{host}:{port}"
+
+
+def is_default_base(url: str) -> bool:
+ """是否默认信任 origin:完整比较 scheme+host+端口(仅查 hostname 会漏掉换端口绕过)。"""
+ return normalize_origin(url) in DEFAULT_ALLOWED_ORIGINS
+
+
+def ensure_allowed_base(cfg: dict) -> None:
+ """fail-closed:非默认 origin 必须已在 approved_origins 里显式批准,否则抛错。
+
+ 单独改 ``base_url`` 字段、或设 ``LLMFILL_BASE_URL``,不能绕过批准——
+ 只有 ``config --allow-custom``(或交互确认)能把 origin 写进 approved_origins。
+ """
+ base = cfg.get("base_url") or DEFAULT_BASE_URL
+ if is_default_base(base):
+ return
+ approved = {normalize_origin(x) for x in cfg.get("approved_origins") or []}
+ if normalize_origin(base) in approved:
+ return
+ raise ConfigError(
+ f"自定义服务端点 {base} 未获批准。默认仅允许 https://www.llmfill.com。\n"
+ "如确需自建/代理部署,请运行:\n"
+ f" python scripts/llmfill.py config --base {base} --allow-custom\n"
+ "(或交互式 config 时确认),批准会持久化到 config.json 的 approved_origins。"
+ )
+
+
+def _tighten_perms(path: Path) -> None:
+ """尽力收紧文件权限为 600(Windows 上 chmod 语义有限,静默失败)。"""
+ try:
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
+ except OSError: # Windows / 受限文件系统
+ pass
+
+
+def load_config(*, require: bool = True, check_approval: bool = True) -> dict:
+ """读取配置;环境变量覆盖 base_url / api_key。
+
+ require=True 且无 api_key 时抛 ConfigError(提示先运行 config)。
+ check_approval=False 供 cmd_config 使用(配置阶段自行处理批准,避免旧值误伤)。
+ """
+ cfg: dict = {}
+ if CONFIG_PATH.exists():
+ try:
+ # utf-8-sig 兼容带 BOM 的配置(Windows 记事本 / PowerShell Set-Content 默认带 BOM)
+ cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8-sig"))
+ except (ValueError, OSError) as exc:
+ raise ConfigError(f"配置文件损坏({CONFIG_PATH}):{exc}\n请重新运行 llmfill config") from exc
+ if not isinstance(cfg, dict):
+ raise ConfigError(f"配置文件格式错误({CONFIG_PATH}):顶层应为 JSON 对象")
+
+ env_base = os.environ.get("LLMFILL_BASE_URL")
+ env_key = os.environ.get("LLMFILL_API_KEY")
+ if env_base:
+ cfg["base_url"] = env_base
+ if env_key:
+ cfg["api_key"] = env_key
+
+ cfg.setdefault("base_url", DEFAULT_BASE_URL)
+ cfg["base_url"] = validate_base_url(cfg["base_url"], allow_insecure=allow_insecure_http())
+ if check_approval:
+ ensure_allowed_base(cfg)
+
+ if require and not cfg.get("api_key"):
+ raise ConfigError(
+ "未配置 API Key。请运行 `python scripts/llmfill.py config`,"
+ "并前往 https://www.llmfill.com/profile 个人中心「API 密钥」创建令牌后粘贴。"
+ )
+ return cfg
+
+
+def save_config(cfg: dict) -> Path:
+ """写入配置文件(仅保留白名单字段),权限 600。"""
+ clean = {k: cfg.get(k) for k in _VALID_KEYS if cfg.get(k) is not None}
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ CONFIG_PATH.write_text(
+ json.dumps(clean, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ _tighten_perms(CONFIG_PATH)
+ return CONFIG_PATH
+
+
+def save_endpoints(data: dict) -> Path:
+ """discover 命令缓存的接口清单。"""
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ ENDPOINTS_PATH.write_text(
+ json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ return ENDPOINTS_PATH
diff --git a/skills/llmfill/scripts/llmfill.py b/skills/llmfill/scripts/llmfill.py
new file mode 100644
index 0000000000..1c0d7a7e53
--- /dev/null
+++ b/skills/llmfill/scripts/llmfill.py
@@ -0,0 +1,692 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""llmfill - LLMFill命令行工具(Agent Skill 主入口)。
+
+把 merge-service 的文档智能填写与知识库管理能力暴露给 agent/终端:
+上传 .docx 自动填写并下载结果、建库/传资料(供填写时检索)。
+
+用法(agent 常用):
+ python scripts/llmfill.py config # 首次配置(API Key)
+ python scripts/llmfill.py whoami # 令牌自检 + 余额
+ python scripts/llmfill.py discover # 拉取接口清单(自动发现)
+ python scripts/llmfill.py fill 表单.docx [--kb KB_ID] # AI 填写 -> 下载
+ python scripts/llmfill.py kb ls # 知识库列表
+
+注:不提供检索(search)命令 -- 检索是服务端填写链路的内部行为,
+网关只暴露网页前端在用的接口。
+
+认证:请求头 X-Auth-Token: aif_xxx(个人中心「API 密钥」生成)。
+零第三方依赖,Python 3.10+ 标准库实现。
+"""
+
+from __future__ import annotations
+
+import argparse
+import getpass
+import json
+import os
+import re
+import sys
+import time
+from pathlib import Path
+
+from config import (
+ ConfigError,
+ DEFAULT_BASE_URL,
+ allow_insecure_http,
+ is_default_base,
+ load_config,
+ save_config,
+ save_endpoints,
+ validate_base_url,
+)
+from api_client import ApiError, download_file, poll_until_done, request_json
+
+# fill/kb 上传的轮询参数:3s 起步指数退避(上限 15s),总超时 30 分钟
+POLL_INTERVAL = 3.0
+POLL_TIMEOUT = 30 * 60
+
+ANSWER_SOURCES = ("knowledge_base", "internet", "hybrid", "llm_only")
+
+# 处理阶段(status.tasks[].stages 的 key):进度反馈时提示当前在做什么,
+# 解决「长时间卡在 60% 无阶段信息」的体验问题。
+STAGE_LABELS = {
+ "upload": "上传解析",
+ "generate_questions": "生成问题",
+ "retrieve_questions": "检索知识库",
+ "fill_answers": "填写答案",
+ "finalize": "收尾结算",
+}
+# 视为「进行中」的阶段状态取值(服务端字段,防御式覆盖多种写法)
+_ACTIVE_STAGE_STATUS = ("processing", "in_progress", "running", "started")
+
+
+def _current_stage(state: dict) -> str | None:
+ """从批次状态提取当前进行中的阶段名(结构差异防御式,取不到返回 None)。"""
+ for task in state.get("tasks") or []:
+ stages = task.get("stages")
+ if isinstance(stages, dict):
+ for name, info in stages.items():
+ if isinstance(info, dict) and info.get("status") in _ACTIVE_STAGE_STATUS:
+ return name
+ elif isinstance(stages, list):
+ for s in stages:
+ if isinstance(s, dict) and s.get("status") in _ACTIVE_STAGE_STATUS:
+ return s.get("name") or s.get("stage")
+ return None
+
+
+# ---------------------------------------------------------------------------
+# 输出工具
+# ---------------------------------------------------------------------------
+
+
+class Output:
+ """人类可读 / --json 双模式输出。"""
+
+ def __init__(self, as_json: bool) -> None:
+ self.as_json = as_json
+
+ def result(self, data: dict, *, ok: str = "") -> None:
+ """成功输出:--json 打印对象,否则打简短摘要 + 可选提示。"""
+ if self.as_json:
+ print(json.dumps(data, ensure_ascii=False, indent=2))
+ elif ok:
+ print(ok)
+
+ def error(self, exc: Exception) -> None:
+ if self.as_json:
+ print(json.dumps({"success": False, "error": {
+ "code": getattr(exc, "code", "ERROR"),
+ "message": str(exc),
+ }}, ensure_ascii=False))
+ else:
+ print(f"错误:{exc}", file=sys.stderr)
+ if isinstance(exc, ApiError) and exc.code in ("INVALID_TOKEN", "NETWORK_ERROR"):
+ print("提示:请运行 `python scripts/llmfill.py config` 检查配置。", file=sys.stderr)
+
+
+def _read_file_bytes(path: str) -> tuple[str, bytes]:
+ p = Path(path)
+ if not p.is_file():
+ raise ApiError("FILE_NOT_FOUND", f"文件不存在:{path}")
+ return p.name, p.read_bytes()
+
+
+def _mk_api(cfg: dict):
+ """返回绑定了 base_url/api_key 的请求函数。"""
+ return lambda method, path, **kw: request_json(
+ cfg["base_url"], cfg["api_key"], method, path, **kw
+ )
+
+
+# ---------------------------------------------------------------------------
+# 子命令:config / whoami / discover
+# ---------------------------------------------------------------------------
+
+
+def _approve_custom_base(cfg: dict, base_url: str, allow_custom: bool) -> None:
+ """自定义 origin 需显式批准:--allow-custom 或交互确认,并持久化 approved_origins。
+
+ 非交互且未传 --allow-custom 时 fail-closed(警告不能代替同意)。
+ """
+ approved = set(cfg.get("approved_origins") or [])
+ if base_url in approved:
+ return # 已批准,无需再确认
+ if allow_custom:
+ approved.add(base_url)
+ cfg["approved_origins"] = sorted(approved)
+ return
+ if sys.stdin.isatty():
+ print(f"警告:base_url 指向非默认端点 {base_url}(默认 {DEFAULT_BASE_URL})。",
+ file=sys.stderr)
+ ans = input(f" 确认将令牌与文档发送到 {base_url} ?[y/N]: ").strip().lower()
+ if ans in ("y", "yes"):
+ approved.add(base_url)
+ cfg["approved_origins"] = sorted(approved)
+ return
+ raise ApiError("CANCELLED", "已取消:未确认自定义端点")
+ raise ApiError(
+ "CUSTOM_BASE_REQUIRES_APPROVAL",
+ f"自定义端点 {base_url} 需显式批准:请加 --allow-custom 或交互式确认",
+ )
+
+
+# --token-env 仅允许的变量名:不接受任意环境变量,防止误把其它服务的
+# 密钥(云/CI/源码库凭据)当作 LLMFill 令牌读走、落盘并外发
+TOKEN_ENV_ALLOWLIST = ("LLMFILL_API_TOKEN", "LLMFILL_API_KEY")
+
+# 令牌格式:aif_ 前缀 + 限定字符集/长度(与个人中心「API 密钥」生成的格式一致)
+_TOKEN_RE = re.compile(r"^aif_[A-Za-z0-9_-]{4,196}$")
+
+
+def _validate_token(value: str, source: str) -> str:
+ """校验令牌格式(aif_ 前缀 + 字符集/长度),值不匹配时 fail-closed。
+
+ 目的:环境变量/参数指错时(如指向 GITHUB_TOKEN 等无关密钥)在保存与
+ 发送之前就拦下,不把无关密钥写进 config.json 或发给服务端。
+
+ 额外诊断:agent 环境的常见坑是 secret 存进 store 后对模型隐藏,往 env
+ 里传时拿到的是掩码占位符(如 ``***``),而非真实令牌。此时给出针对性
+ 提示,避免用户误以为「上传的令牌错了」。
+ 错误信息只描述格式/长度问题,绝不回显真实令牌值。
+ """
+ v = value.strip()
+ if _TOKEN_RE.match(v):
+ return v
+ if not v:
+ raise ApiError("CONFIG_REQUIRED", f"{source} 未取到令牌值(空)。")
+ if "*" in v or v.lower() in ("your_token_here", ""):
+ # 收到掩码/占位符:几乎可以断定是 secret 未注入环境变量
+ raise ApiError(
+ "CONFIG_REQUIRED",
+ f"{source} 收到的是掩码占位符(含 ``*``),不是真实令牌——说明 secret "
+ "未真正注入到环境变量(agent 环境常见:secrets 存的值对模型隐藏)。"
+ "请改为让用户本人运行 `python scripts/llmfill.py config` 交互式输入,"
+ "或直接把令牌写入 ~/.llmfill/config.json 的 api_key 字段。",
+ )
+ # secret 引用/占位:agent 平台往本地 exec 的 env 传 secret 时,拿到的常是
+ # `store:LLMFILL_API_TOKEN` 这类引用串,而非真实值。真实令牌(aif_+安全字符)
+ # 绝不含冒号/尖括号/花括号,故凡含这些即可断定是「引用未展开」。
+ if ":" in v or v[0] in "<{":
+ raise ApiError(
+ "CONFIG_REQUIRED",
+ f"{source} 收到的是 secret 引用/占位符({v[:24]!r}...),不是真实令牌——"
+ "agent 平台的 secrets 不会注入到本地脚本的环境变量。"
+ "请改为让用户本人运行 `python scripts/llmfill.py config` 交互式输入,"
+ "或直接把令牌写入 ~/.llmfill/config.json 的 api_key 字段。",
+ )
+ if not v.startswith("aif_"):
+ raise ApiError(
+ "CONFIG_REQUIRED",
+ f"{source} 的值不是 aif_ 开头的令牌(当前收到 {len(v)} 个字符)。"
+ "可能误指向了其它服务的密钥变量,或 secret 未注入;"
+ "请用专用变量 LLMFILL_API_TOKEN。",
+ )
+ raise ApiError(
+ "CONFIG_REQUIRED",
+ f"{source} 的值以 aif_ 开头,但含非法字符或长度不符(共 {len(v)} 个字符)。"
+ "请重新从个人中心完整复制令牌。",
+ )
+
+
+def cmd_config(args, out: Output) -> None:
+ """首次配置:只填 API Key(base_url 内置默认,改 JSON/env 可覆盖)。"""
+ cfg = load_config(require=False, check_approval=False)
+
+ # base_url 不提示用户输入:默认内置,需要改的场景(自建/代理部署)
+ # 用 --base 参数(自定义 origin 需 --allow-custom 或交互确认)。
+ base_url = validate_base_url(
+ args.base or cfg.get("base_url") or DEFAULT_BASE_URL,
+ allow_insecure=allow_insecure_http(),
+ )
+ if not is_default_base(base_url):
+ _approve_custom_base(cfg, base_url, args.allow_custom)
+ # 令牌来源优先级:--token-env(推荐,不进 shell 历史)> --token(兼容保留)
+ # > 交互输入(getpass 不回显)> 存量配置;新输入的令牌先做格式校验
+ api_key = ""
+ new_token = False # 本次是否输入了新令牌(决定验证失败时是否保留旧值)
+ if args.token_env:
+ # 从环境变量读令牌:不进 shell 历史/进程列表,终端也不回显。
+ # 仅允许专用变量名,防止误读无关密钥(对应审计 T09)
+ if args.token_env not in TOKEN_ENV_ALLOWLIST:
+ raise ApiError(
+ "CONFIG_REQUIRED",
+ f"--token-env 仅允许 {TOKEN_ENV_ALLOWLIST[0]}(推荐)或 "
+ f"{TOKEN_ENV_ALLOWLIST[1]},不接受任意环境变量——防止误把其它服务的"
+ "密钥存入配置并发送给服务端。",
+ )
+ api_key = os.environ.get(args.token_env, "").strip()
+ if not api_key:
+ raise ApiError(
+ "CONFIG_REQUIRED", f"环境变量 {args.token_env} 未设置或为空"
+ )
+ api_key = _validate_token(api_key, f"环境变量 {args.token_env}")
+ new_token = True
+ elif args.token:
+ api_key = _validate_token(args.token, "--token")
+ new_token = True
+ elif sys.stdin.isatty():
+ # 交互输入用 getpass(不回显):防旁观/录屏/会话日志截获长效令牌
+ try:
+ print(" API Key:前往 https://www.llmfill.com/profile 个人中心「API 密钥」")
+ print(" 创建令牌后,粘贴 aif_ 开头的字符串(输入不回显;直接回车保留已存令牌):")
+ entered = getpass.getpass(" API Key: ").strip()
+ if entered:
+ api_key = _validate_token(entered, "交互输入")
+ new_token = True
+ except (EOFError, KeyboardInterrupt):
+ raise ApiError(
+ "CONFIG_REQUIRED", "交互输入不可用:请用 --token-env 或 --token 配置"
+ )
+
+ api_key = api_key or cfg.get("api_key") or ""
+ if not api_key:
+ raise ApiError("CONFIG_REQUIRED", "缺少 API Key(--token-env / --token 或交互输入)")
+
+ # 先远程验证,通过后才把候选令牌落盘(对应审计 T09:验证失败不持久化新凭据)
+ old_stored = cfg.get("api_key")
+ try:
+ whoami = request_json(base_url, api_key, "GET", "/v1/account/whoami")
+ except ApiError as exc:
+ if new_token:
+ # 新令牌验证失败:不保存该候选值,恢复存量旧令牌
+ # (自定义端点批准结果仍保留,便于重试)
+ cfg["base_url"] = base_url
+ if old_stored:
+ cfg["api_key"] = old_stored
+ else:
+ cfg.pop("api_key", None)
+ path = save_config(cfg)
+ out.result({"saved": str(path), "verified": False, "error": str(exc)},
+ ok=f"令牌验证失败,未写入 {path}:{exc}")
+ else:
+ cfg.update({"base_url": base_url, "api_key": api_key})
+ path = save_config(cfg)
+ out.result({"saved": str(path), "verified": False, "error": str(exc)},
+ ok=f"已保存到 {path},但自检失败:{exc}")
+ return
+
+ user_id = whoami.get("user_id")
+ cfg.update({"base_url": base_url, "api_key": api_key, "user_id": user_id})
+ path = save_config(cfg)
+ balance = whoami.get("balance")
+ bal_txt = f",余额 {balance}" if balance is not None else ""
+ identity = _identity_text(whoami)
+ out.result(
+ {
+ "saved": str(path), "verified": True, "user_id": user_id,
+ "token_name": whoami.get("token_name"),
+ "email_masked": whoami.get("email_masked"),
+ "balance": balance,
+ },
+ ok=f"配置完成并验证通过:{identity}{bal_txt}(已写入 {path})",
+ )
+
+
+def _identity_text(data: dict) -> str:
+ """身份回显文案:账号脱敏邮箱 + 令牌名称 + user_id,帮用户确认令牌归属。"""
+ parts = []
+ if data.get("email_masked"):
+ parts.append(f"账号 {data['email_masked']}")
+ if data.get("token_name"):
+ parts.append(f"令牌「{data['token_name']}」")
+ parts.append(f"user_id={data.get('user_id')}")
+ return ",".join(parts)
+
+
+def cmd_whoami(args, out: Output) -> None:
+ cfg = load_config()
+ data = request_json(cfg["base_url"], cfg["api_key"], "GET", "/v1/account/whoami")
+ out.result(data, ok=(
+ f"令牌有效:{_identity_text(data)}"
+ + (f",余额 {data.get('balance')}" if data.get("balance") is not None else "")
+ ))
+
+
+def cmd_discover(args, out: Output) -> None:
+ """拉取网关聚合 OpenAPI,打印端点清单并缓存到 ~/.llmfill/endpoints.json。"""
+ cfg = load_config()
+ spec = request_json(cfg["base_url"], cfg["api_key"], "GET", "/v1/openapi")
+
+ summary = {}
+ for path, ops in spec.get("paths", {}).items():
+ methods = [m.upper() for m in ops if m in ("get", "post", "put", "delete", "patch")]
+ summary[path] = methods
+
+ save_endpoints({"base_url": cfg["base_url"], "paths": summary})
+ if out.as_json:
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
+ else:
+ print(f"发现 {len(summary)} 个端点(已缓存到 ~/.llmfill/endpoints.json):")
+ for path, methods in sorted(summary.items()):
+ print(f" {','.join(methods):<18} {path}")
+
+
+# ---------------------------------------------------------------------------
+# 子命令:fill(文档智能填写)
+# ---------------------------------------------------------------------------
+
+
+def _download_batch_results(cfg: dict, batch_id: str, out_dir: Path, *, clean_only: bool = False) -> list[Path]:
+ """下载批次结果:默认同时下载标注版(_processed)与纯净版(_clean)。
+
+ 返回下载到的文件绝对路径列表(标注版在前、纯净版在后);clean_only=True
+ 时只下纯净版。两个版本由网关 Content-Disposition 区分文件名(如
+ `xxx_processed.docx` / `xxx_clean.docx`),互不覆盖。
+ """
+ result_path = f"/v1/documents/{batch_id}/result"
+ targets: list[Path] = []
+ if not clean_only:
+ targets.append(download_file(cfg["base_url"], cfg["api_key"], result_path, out_dir))
+ targets.append(download_file(cfg["base_url"], cfg["api_key"], f"{result_path}?clean=true", out_dir))
+ return targets
+
+
+def cmd_fill(args, out: Output) -> None:
+ """上传 -> 轮询 -> 下载,端到端填写。"""
+ cfg = load_config()
+ api = _mk_api(cfg)
+
+ files = [_read_file_bytes(p) for p in args.files]
+ if not files:
+ raise ApiError("VALIDATION_ERROR", "请至少提供一个 .docx 文件")
+
+ # 预估费用:独立 /estimate 端点(upload 响应不含 cost_estimate)。
+ # 仅供展示,实扣以完成后 status 的 tasks[].cost 为准;estimate 失败不阻塞提交。
+ estimate = None
+ try:
+ est_resp = api("POST", "/v1/documents/estimate",
+ files=[("files", n, b) for n, b in files])
+ estimate = est_resp.get("total_cost") if isinstance(est_resp, dict) else None
+ except ApiError:
+ estimate = None
+
+ # 上传(multipart:files[] + answer_source + kb_ids)。
+ # 费用链路:余额不足时上游入队前 402 拒单(message 带预估金额与充值
+ # 指引);实扣金额在完成后从 status 的 tasks[].cost 提取(actual_cost)。
+ form: dict[str, str] = {"answer_source": args.source}
+ if args.kb:
+ form["kb_ids"] = json.dumps(args.kb)
+ if args.mode:
+ form["mode"] = args.mode
+
+ upload_resp = api(
+ "POST", "/v1/documents/upload",
+ form_fields=form,
+ files=[("files", n, b) for n, b in files],
+ )
+ batch_id = upload_resp.get("batch_id")
+ if not batch_id:
+ raise ApiError("PARSE_ERROR", f"上传响应缺少 batch_id:{upload_resp}")
+
+ total = upload_resp.get("total_count", len(files))
+ est_txt = f",预估费用约 {estimate} 元(实际以处理结果为准)" if estimate is not None else ""
+
+ if not out.as_json:
+ print(f"已提交批次 {batch_id}({total} 个文件){est_txt},开始轮询处理进度…", file=sys.stderr)
+
+ # 轮询批次状态
+ def fetch():
+ return api("GET", f"/v1/documents/{batch_id}/status")
+
+ def done(state):
+ return state.get("status") in ("completed", "partial", "failed")
+
+ def on_tick(ticks, state):
+ if not out.as_json and ticks % 3 == 0: # 每 3 次轮询报一次进度,避免刷屏
+ stage = _current_stage(state)
+ stage_txt = f",阶段:{STAGE_LABELS.get(stage, stage)}" if stage else ""
+ print(f" 进度 {state.get('progress', 0)}%"
+ f"({state.get('completed_count', 0)}/{state.get('total_count', '?')} 完成){stage_txt}")
+
+ state = poll_until_done(
+ interval=POLL_INTERVAL, timeout=POLL_TIMEOUT,
+ poll_fn=fetch, is_done=done, on_tick=on_tick,
+ )
+
+ if state.get("status") == "failed":
+ raise ApiError("TASK_FAILED", f"批次处理失败:{state.get('failed_count', '?')} 个文件失败")
+
+ # 实际费用:status 响应的 tasks[].cost 是 finalize 阶段按真实 token
+ # 用量算出的实扣金额(与 auth-service 记账同值),预估只作参考。
+ actual_cost = round(sum(float(t.get("cost") or 0) for t in state.get("tasks", [])), 6)
+ cost_line = f",实际费用 {actual_cost} 元" if actual_cost else ""
+
+ # 输出目录:未显式指定 --out 时落到第一个模板所在目录(避免结果散落到
+ # 调用时的工作目录);显式指定则原样使用。
+ out_dir = Path(args.out) if args.out else Path(args.files[0]).resolve().parent
+
+ if not args.no_download:
+ targets = _download_batch_results(cfg, batch_id, out_dir, clean_only=args.clean)
+ downloaded = [str(t.resolve()) for t in targets]
+ out.result({"batch_id": batch_id, "status": state.get("status"),
+ "cost_estimate": estimate, "actual_cost": actual_cost,
+ "downloaded": downloaded},
+ ok=f"处理完成({state.get('status')}){cost_line},结果已下载:{'、'.join(downloaded)}")
+ else:
+ out.result({"batch_id": batch_id, "status": state.get("status"),
+ "cost_estimate": estimate, "actual_cost": actual_cost},
+ ok=f"处理完成({state.get('status')}){cost_line},用 fill-download {batch_id} 下载")
+
+
+def cmd_fill_status(args, out: Output) -> None:
+ cfg = load_config()
+ api = _mk_api(cfg)
+ state = api("GET", f"/v1/documents/{args.batch_id}/status")
+ actual_cost = round(sum(float(t.get("cost") or 0) for t in state.get("tasks", [])), 6)
+ cost_line = f",实际费用 {actual_cost} 元" if actual_cost else ""
+ out.result(state, ok=(
+ f"批次 {args.batch_id}:{state.get('status')}(进度 {state.get('progress', 0)}%,"
+ f"完成 {state.get('completed_count', 0)}/{state.get('total_count', '?')},"
+ f"失败 {state.get('failed_count', 0)}{cost_line})"
+ ))
+
+
+def cmd_fill_download(args, out: Output) -> None:
+ cfg = load_config()
+ targets = _download_batch_results(cfg, args.batch_id, Path(args.out), clean_only=args.clean)
+ downloaded = [str(t.resolve()) for t in targets]
+ out.result({"batch_id": args.batch_id, "downloaded": downloaded},
+ ok=f"已下载:{'、'.join(downloaded)}")
+
+
+# ---------------------------------------------------------------------------
+# 子命令:kb(知识库管理)
+# ---------------------------------------------------------------------------
+
+
+def cmd_kb(args, out: Output) -> None:
+ cfg = load_config()
+ api = _mk_api(cfg)
+
+ if args.kb_cmd == "ls":
+ data = api("GET", "/v1/knowledge-bases")
+ if out.as_json:
+ print(json.dumps(data, ensure_ascii=False, indent=2))
+ else:
+ kbs = data.get("kbs", [])
+ print(f"共 {len(kbs)} 个知识库:")
+ for kb in kbs:
+ print(f" {kb.get('kb_id')} {kb.get('name', '')}"
+ f"({kb.get('document_count', 0)} 文档)")
+ return
+
+ if args.kb_cmd == "create":
+ data = api("POST", "/v1/knowledge-bases",
+ json_body={"name": args.name, "description": args.desc or ""})
+ out.result(data, ok=f"已创建知识库 {data.get('kb_id')}({args.name})")
+ return
+
+ if args.kb_cmd == "rm":
+ # 删除不可恢复:交互环境需确认,非交互环境必须显式 --yes(防 agent 误删/注入误删)
+ if not args.yes:
+ if sys.stdin.isatty():
+ print(f"即将删除知识库 {args.kb_id}(不可恢复),确认?[y/N]: ", file=sys.stderr)
+ if input().strip().lower() not in ("y", "yes"):
+ raise ApiError("CANCELLED", "已取消删除")
+ else:
+ raise ApiError(
+ "CONFIRM_REQUIRED",
+ f"删除知识库 {args.kb_id} 是不可恢复操作,请加 --yes 确认",
+ )
+ data = api("DELETE", f"/v1/knowledge-bases/{args.kb_id}")
+ out.result(data, ok=f"已删除知识库 {args.kb_id}")
+ return
+
+ if args.kb_cmd == "docs":
+ data = api("GET", f"/v1/knowledge-bases/{args.kb_id}/documents")
+ if out.as_json:
+ print(json.dumps(data, ensure_ascii=False, indent=2))
+ else:
+ docs = data.get("documents", [])
+ print(f"知识库 {args.kb_id} 共 {len(docs)} 个文档:")
+ for d in docs:
+ print(f" {d.get('document_id', '')[:16]}… {d.get('filename', '')}"
+ f"({d.get('parse_status', '?')})")
+ return
+
+ if args.kb_cmd == "upload":
+ files = [_read_file_bytes(p) for p in args.files]
+ failed: list[str] = []
+ uploaded: list[dict] = []
+ # 上传前文档快照:用于在列表中识别本次上传的新文档(网关不暴露
+ # /tasks 任务查询,入库进度由文档列表 parse_status 派生,与前端一致)
+ try:
+ before = {
+ (d.get("filename"), d.get("document_id"))
+ for d in api("GET", f"/v1/knowledge-bases/{args.kb_id}/documents").get("documents", [])
+ }
+ except ApiError:
+ before = set()
+
+ for name, content in files:
+ # 单文件失败(含上传被拒/入库失败)不阻塞其余文件,最终汇总退出码
+ try:
+ api("POST", f"/v1/knowledge-bases/{args.kb_id}/documents",
+ files=[("file", name, content)])
+ except ApiError as exc:
+ print(f"{name}:上传失败 - {exc.message}", file=sys.stderr)
+ failed.append(name)
+ continue
+ if not out.as_json:
+ print(f"{name}:已提交,轮询入库状态…", file=sys.stderr)
+
+ def fetch(kb_id=args.kb_id, fname=name, snap=before):
+ docs = api("GET", f"/v1/knowledge-bases/{kb_id}/documents").get("documents", [])
+ # 本次上传的文档 = 同名且不在快照里的最新一条
+ new_docs = [d for d in docs
+ if d.get("filename") == fname
+ and (d.get("filename"), d.get("document_id")) not in snap]
+ return new_docs[0] if new_docs else {"parse_status": "parsing"}
+
+ doc = poll_until_done(
+ interval=POLL_INTERVAL, timeout=POLL_TIMEOUT, poll_fn=fetch,
+ is_done=lambda d: d.get("parse_status") in ("completed", "failed"),
+ )
+ if doc.get("parse_status") == "failed":
+ print(f"{name}:入库失败(可删除后重传)", file=sys.stderr)
+ failed.append(name)
+ else:
+ uploaded.append({
+ "filename": name,
+ "document_id": doc.get("document_id"),
+ "parse_status": doc.get("parse_status"),
+ })
+ if not out.as_json:
+ print(f"{name}:入库完成(可检索)")
+ if failed:
+ raise ApiError("UPLOAD_FAILED", f"{len(failed)} 个文件失败:{'、'.join(failed)}")
+ # --json 时同样输出结果(此前 upload 分支在 JSON 模式下无任何输出,
+ # 导致 agent 只能再调 kb docs 确认入库状态)
+ out.result(
+ {"kb_id": args.kb_id, "uploaded": uploaded, "failed": failed},
+ ok=f"已上传 {len(uploaded)} 个文件到知识库 {args.kb_id}(入库完成,可检索)",
+ )
+ return
+
+ raise ApiError("VALIDATION_ERROR", f"未知 kb 子命令:{args.kb_cmd}")
+
+
+# ---------------------------------------------------------------------------
+# argparse 装配
+# ---------------------------------------------------------------------------
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="llmfill",
+ description="LLMFill:Word 智能填写 + 知识库管理(远程服务调用)。"
+ " 任意位置加 --json 输出机器可读 JSON(agent 友好)。",
+ )
+ sub = parser.add_subparsers(dest="cmd", required=True)
+
+ # config
+ p = sub.add_parser("config", help="首次配置(API Key + Base URL)并自检")
+ p.add_argument("--base", help="覆盖服务地址(默认内置 www.llmfill.com;自定义地址需 --allow-custom)")
+ p.add_argument("--token-env", metavar="NAME",
+ help="从环境变量读取 API Key,仅允许 LLMFILL_API_TOKEN / "
+ "LLMFILL_API_KEY(推荐:不进 shell 历史、终端不回显)")
+ p.add_argument("--token", help="API Key 明文参数(会留在 shell 历史,建议改用 --token-env)")
+ p.add_argument("--allow-custom", action="store_true", help="批准非默认的自定义服务地址(自建/代理部署)")
+ p.set_defaults(func=cmd_config)
+
+ # whoami
+ p = sub.add_parser("whoami", help="令牌自检与余额查询")
+ p.set_defaults(func=cmd_whoami)
+
+ # discover
+ p = sub.add_parser("discover", help="拉取接口清单(agent 自动发现)")
+ p.set_defaults(func=cmd_discover)
+
+ # fill
+ p = sub.add_parser("fill", help="上传 .docx 智能填写并下载结果")
+ p.add_argument("files", nargs="+", help=".docx 文件路径(可多个)")
+ p.add_argument("--source", default="hybrid", choices=ANSWER_SOURCES,
+ help="答案来源(默认 hybrid)")
+ p.add_argument("--kb", action="append", help="挂载的知识库 ID(可多次)")
+ p.add_argument("--mode", default="fast", choices=("fast", "economy"),
+ help="处理模式(默认 fast)")
+ p.add_argument("--clean", action="store_true", help="只下载纯净版(默认同时下载标注版+纯净版)")
+ p.add_argument("--no-download", action="store_true", help="完成后不自动下载")
+ p.add_argument("--out", default=None, help="下载目录(默认:第一个模板所在目录)")
+ p.set_defaults(func=cmd_fill)
+
+ # fill-status / fill-download
+ p = sub.add_parser("fill-status", help="查询批次处理状态")
+ p.add_argument("batch_id")
+ p.set_defaults(func=cmd_fill_status)
+
+ p = sub.add_parser("fill-download", help="下载批次结果")
+ p.add_argument("batch_id")
+ p.add_argument("--clean", action="store_true", help="只下载纯净版(默认同时下载标注版+纯净版)")
+ p.add_argument("--out", default=".", help="下载目录(默认当前目录)")
+ p.set_defaults(func=cmd_fill_download)
+
+ # kb
+ p = sub.add_parser("kb", help="知识库管理")
+ kb_sub = p.add_subparsers(dest="kb_cmd", required=True)
+ kb_sub.add_parser("ls", help="列出知识库")
+ q = kb_sub.add_parser("create", help="创建知识库")
+ q.add_argument("--name", required=True)
+ q.add_argument("--desc", default="")
+ q = kb_sub.add_parser("rm", help="删除知识库(不可恢复)")
+ q.add_argument("kb_id")
+ q.add_argument("--yes", action="store_true", help="跳过确认,直接删除")
+ q = kb_sub.add_parser("docs", help="列出知识库下文档")
+ q.add_argument("kb_id")
+ q = kb_sub.add_parser("upload", help="上传文件到知识库(异步,自动轮询)")
+ q.add_argument("kb_id")
+ q.add_argument("files", nargs="+")
+ p.set_defaults(func=cmd_kb)
+
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ # --json 是全局开关,手动从 argv 剥离后单独处理,使其可放在任意位置
+ # (whoami --json 与 --json whoami 等价)。argparse 的全局参数只能放
+ # 在子命令前,会拒绝最自然的 `whoami --json` 写法。
+ argv = list(sys.argv[1:] if argv is None else argv)
+ as_json = "--json" in argv
+ argv = [a for a in argv if a != "--json"]
+
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ out = Output(as_json)
+ try:
+ args.func(args, out)
+ return 0
+ except ConfigError as exc:
+ out.error(exc)
+ return 2
+ except ApiError as exc:
+ out.error(exc)
+ return 1
+ except KeyboardInterrupt:
+ print("\n已中断", file=sys.stderr)
+ return 130
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/llmfill/tests/test_llmfill.py b/skills/llmfill/tests/test_llmfill.py
new file mode 100644
index 0000000000..9d7f7097c4
--- /dev/null
+++ b/skills/llmfill/tests/test_llmfill.py
@@ -0,0 +1,784 @@
+# -*- coding: utf-8 -*-
+"""llmfill skill 脚本层测试:配置读写 / 错误解析 / multipart / 轮询 / CLI。
+
+不依赖真实服务:api_client 的网络函数用 monkeypatch 替换。
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
+
+import api_client # noqa: E402
+import config as config_mod # noqa: E402
+from api_client import ApiError, _encode_multipart, _filename_from_disposition, _parse_error_body, _safe_filename, poll_until_done # noqa: E402
+
+
+class _FakeStdin:
+ """模拟非交互 stdin(isatty=False),用于测试需确认/拒绝的分支。"""
+
+ def isatty(self):
+ return False
+
+
+class _TtyStdin:
+ """模拟交互 stdin(isatty=True),配合 mock getpass 测试交互输入。"""
+
+ def isatty(self):
+ return True
+
+
+# ---------------------------------------------------------------------------
+# config.py
+# ---------------------------------------------------------------------------
+
+
+class TestConfig:
+ def test_save_load_roundtrip(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "config.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ config_mod.save_config({"base_url": "https://www.llmfill.com", "api_key": "aif_xxxx1234"})
+ cfg = config_mod.load_config()
+ assert cfg["base_url"] == "https://www.llmfill.com"
+ assert cfg["api_key"] == "aif_xxxx1234"
+
+ def test_env_overrides_file(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "config.json")
+ config_mod.save_config({"api_key": "aif_from_file", "base_url": "https://www.llmfill.com"})
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_from_env")
+ cfg = config_mod.load_config()
+ assert cfg["api_key"] == "aif_from_env"
+ assert cfg["base_url"] == "https://www.llmfill.com"
+
+ def test_require_without_key_raises(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "nonexistent.json")
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ with pytest.raises(config_mod.ConfigError, match="未配置"):
+ config_mod.load_config()
+
+ def test_corrupted_file_raises(self, tmp_path, monkeypatch):
+ p = tmp_path / "config.json"
+ p.write_text("{not json", encoding="utf-8")
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", p)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ with pytest.raises(config_mod.ConfigError, match="损坏"):
+ config_mod.load_config()
+
+ def test_load_config_tolerates_utf8_bom(self, tmp_path, monkeypatch):
+ """带 UTF-8 BOM 的配置(Windows 记事本/PowerShell 默认)应正常读取,不报损坏。"""
+ p = tmp_path / "config.json"
+ payload = json.dumps({"api_key": "aif_xxxx1234", "base_url": "https://www.llmfill.com"})
+ p.write_bytes(b"\xef\xbb\xbf" + payload.encode("utf-8"))
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", p)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ cfg = config_mod.load_config()
+ assert cfg["api_key"] == "aif_xxxx1234"
+
+ def test_default_base_url(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "nonexistent.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ cfg = config_mod.load_config()
+ assert cfg["base_url"] == config_mod.DEFAULT_BASE_URL
+
+ # ---- validate_base_url(T09:未校验外发目的地)----
+
+ def test_validate_rejects_http(self):
+ with pytest.raises(config_mod.ConfigError, match="HTTPS"):
+ config_mod.validate_base_url("http://www.llmfill.com")
+
+ def test_validate_rejects_userinfo(self):
+ with pytest.raises(config_mod.ConfigError, match="用户名/密码"):
+ config_mod.validate_base_url("https://user:pass@www.llmfill.com")
+
+ def test_validate_rejects_fragment(self):
+ with pytest.raises(config_mod.ConfigError, match="锚点"):
+ config_mod.validate_base_url("https://www.llmfill.com/#x")
+
+ def test_validate_rejects_query(self):
+ with pytest.raises(config_mod.ConfigError, match="查询串"):
+ config_mod.validate_base_url("https://www.llmfill.com/?redirect=evil")
+
+ def test_validate_rejects_path(self):
+ with pytest.raises(config_mod.ConfigError, match="origin"):
+ config_mod.validate_base_url("https://www.llmfill.com/evil")
+
+ def test_validate_normalizes(self):
+ assert config_mod.validate_base_url("https://www.llmfill.com/") == "https://www.llmfill.com"
+ assert config_mod.validate_base_url("https://www.llmfill.com:8443/") == "https://www.llmfill.com:8443"
+
+ def test_load_config_rejects_bad_env_base(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "none.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ monkeypatch.setenv("LLMFILL_BASE_URL", "http://evil.example.com")
+ with pytest.raises(config_mod.ConfigError, match="HTTPS"):
+ config_mod.load_config()
+
+ def test_validate_allows_http_when_insecure(self):
+ assert config_mod.validate_base_url(
+ "http://127.0.0.1:8000", allow_insecure=True
+ ) == "http://127.0.0.1:8000"
+
+ def test_validate_still_rejects_http_by_default(self):
+ with pytest.raises(config_mod.ConfigError, match="HTTPS"):
+ config_mod.validate_base_url("http://127.0.0.1:8000")
+
+ # ---- ensure_allowed_base(fail-closed 批准模型)----
+
+ def test_ensure_allowed_base_default_ok(self):
+ config_mod.ensure_allowed_base({"base_url": "https://www.llmfill.com"})
+ config_mod.ensure_allowed_base({"base_url": "https://llmfill.com"})
+ # 显式默认端口(:443)与省略等价
+ config_mod.ensure_allowed_base({"base_url": "https://www.llmfill.com:443"})
+
+ def test_nondefault_port_requires_approval(self):
+ """regression:非默认端口即使 host 在名单内也需批准(仅查 hostname 的绕过)。"""
+ with pytest.raises(config_mod.ConfigError, match="未获批准"):
+ config_mod.ensure_allowed_base({"base_url": "https://www.llmfill.com:8443"})
+ with pytest.raises(config_mod.ConfigError, match="未获批准"):
+ config_mod.ensure_allowed_base({"base_url": "https://llmfill.com:444"})
+
+ def test_normalize_origin_equivalence(self):
+ assert config_mod.normalize_origin("https://www.llmfill.com:443/x") == "https://www.llmfill.com"
+ assert config_mod.normalize_origin("https://WWW.LLMFill.com") == "https://www.llmfill.com"
+ assert config_mod.normalize_origin("https://www.llmfill.com:8443") == "https://www.llmfill.com:8443"
+ assert config_mod.normalize_origin("http://127.0.0.1:8000") == "http://127.0.0.1:8000"
+
+ def test_ensure_allowed_base_custom_unapproved_raises(self):
+ with pytest.raises(config_mod.ConfigError, match="未获批准"):
+ config_mod.ensure_allowed_base({"base_url": "https://private.example"})
+
+ def test_ensure_allowed_base_custom_approved_ok(self):
+ config_mod.ensure_allowed_base({
+ "base_url": "https://private.example",
+ "approved_origins": ["https://private.example"],
+ })
+
+ def test_load_config_custom_env_unapproved_raises(self, tmp_path, monkeypatch):
+ """LLMFILL_BASE_URL 单独不能授权自定义 origin(fail-closed)。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "none.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ monkeypatch.setenv("LLMFILL_BASE_URL", "https://private.example")
+ with pytest.raises(config_mod.ConfigError, match="未获批准"):
+ config_mod.load_config()
+
+ def test_load_config_custom_approved_ok(self, tmp_path, monkeypatch):
+ """approved_origins 已批准的自定义 origin 可正常 load。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ config_mod.save_config({
+ "base_url": "https://private.example",
+ "api_key": "aif_xxxx1234",
+ "approved_origins": ["https://private.example"],
+ })
+ cfg = config_mod.load_config()
+ assert cfg["base_url"] == "https://private.example"
+
+
+# ---------------------------------------------------------------------------
+# api_client.py:错误解析
+# ---------------------------------------------------------------------------
+
+
+class TestErrorParsing:
+ def test_unified_gateway_error(self):
+ raw = json.dumps({"success": False, "error": {
+ "code": "INVALID_TOKEN", "message": "API 令牌无效", "request_id": "req_x",
+ }}).encode("utf-8")
+ err = _parse_error_body(raw, 401)
+ assert err.code == "INVALID_TOKEN"
+ assert err.message == "API 令牌无效"
+ assert err.status == 401
+
+ def test_legacy_detail_error(self):
+ raw = json.dumps({"detail": "批次不存在: b_x"}).encode("utf-8")
+ err = _parse_error_body(raw, 404)
+ assert err.code == "HTTP_ERROR"
+ assert err.message == "批次不存在: b_x"
+
+ def test_legacy_401_maps_to_invalid_token(self):
+ raw = json.dumps({"detail": "凭证无效"}).encode("utf-8")
+ err = _parse_error_body(raw, 401)
+ assert err.code == "INVALID_TOKEN"
+
+ def test_non_json_body(self):
+ err = _parse_error_body(b"502", 502)
+ assert err.code == "HTTP_ERROR"
+ assert "502" in err.message
+
+
+# ---------------------------------------------------------------------------
+# api_client.py:multipart 与文件名
+# ---------------------------------------------------------------------------
+
+
+class TestMultipart:
+ def test_encode_fields_and_file(self):
+ body = _encode_multipart(
+ "BOUNDARY",
+ {"mode": "fast", "kb_ids": '["kb-1"]'},
+ [("files", "报告.docx", b"docx-bytes")],
+ )
+ text = body.decode("utf-8")
+ assert 'name="mode"' in text and "fast" in text
+ assert 'name="kb_ids"' in text and "kb-1" in text
+ assert 'name="files"' in text
+ # filename= 直接包含 UTF-8 中文名(服务端 latin-1 解码后 repair 还原)
+ assert 'filename="报告.docx"' in text
+ # 同时携带 RFC 5987 filename*= 作为兼容
+ assert "filename*=UTF-8''" in text
+ assert "%E6%8A%A5%E5%91%8A.docx" in text # "报告" 的 UTF-8 URL 编码
+ assert body.endswith(b"--BOUNDARY--\r\n")
+
+ def test_filename_quoting(self):
+ body = _encode_multipart("B", {}, [("file", 'we"ird\nname.txt', b"x")])
+ text = body.decode("utf-8")
+ assert '"' not in text.split('filename="')[1].split('"')[0]
+
+ def test_disposition_plain(self):
+ assert _filename_from_disposition('attachment; filename="result.zip"') == "result.zip"
+
+ def test_disposition_utf8(self):
+ assert _filename_from_disposition(
+ "attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A.docx"
+ ) == "报告.docx"
+
+ def test_disposition_missing(self):
+ assert _filename_from_disposition("") is None
+
+
+class TestSafeFilename:
+ """_safe_filename:服务端文件名视为不可信输入,防路径穿越。"""
+
+ def test_plain(self):
+ assert _safe_filename("result.docx") == "result.docx"
+
+ def test_posix_traversal(self):
+ assert _safe_filename("../../etc/passwd") == "passwd"
+
+ def test_absolute_posix(self):
+ assert _safe_filename("/etc/passwd") == "passwd"
+
+ def test_windows_traversal(self):
+ assert _safe_filename("..\\..\\system32\\x.exe") == "x.exe"
+
+ def test_windows_drive(self):
+ assert _safe_filename("C:\\Windows\\System32\\cmd.exe") == "cmd.exe"
+
+ def test_mixed_separators(self):
+ assert _safe_filename("../a\\b/结果.docx") == "结果.docx"
+
+ def test_dotdot_only(self):
+ assert _safe_filename("..") == "download.bin"
+
+ def test_empty(self):
+ assert _safe_filename("") == "download.bin"
+
+ def test_control_chars_stripped(self):
+ assert _safe_filename("a\r\nb\x00c") == "abc"
+
+
+class TestRedirectHandler:
+ """_SameOriginRedirectHandler:仅同源重定向,防 X-Auth-Token 跨域泄露。"""
+
+ def _req(self):
+ return urllib.request.Request(
+ "https://www.llmfill.com/v1/x", headers={"X-Auth-Token": "aif_xxxx1234"}
+ )
+
+ def test_same_origin_allowed(self):
+ h = api_client._SameOriginRedirectHandler()
+ new = h.redirect_request(
+ self._req(), None, 302, "Found", {}, "https://www.llmfill.com/v1/y"
+ )
+ assert new.full_url == "https://www.llmfill.com/v1/y"
+
+ def test_cross_origin_rejected(self):
+ h = api_client._SameOriginRedirectHandler()
+ with pytest.raises(urllib.error.HTTPError):
+ h.redirect_request(
+ self._req(), None, 302, "Found", {}, "https://evil.com/v1/x"
+ )
+
+ def test_https_downgrade_rejected(self):
+ h = api_client._SameOriginRedirectHandler()
+ with pytest.raises(urllib.error.HTTPError):
+ h.redirect_request(
+ self._req(), None, 301, "Moved", {}, "http://www.llmfill.com/v1/x"
+ )
+
+ def test_port_change_rejected(self):
+ h = api_client._SameOriginRedirectHandler()
+ with pytest.raises(urllib.error.HTTPError):
+ h.redirect_request(
+ self._req(), None, 302, "Found", {}, "https://www.llmfill.com:8443/v1/x"
+ )
+
+
+# ---------------------------------------------------------------------------
+# api_client.py:轮询
+# ---------------------------------------------------------------------------
+
+
+class TestPolling:
+ def test_poll_until_done(self):
+ states = [{"status": "processing"}, {"status": "processing"}, {"status": "completed"}]
+ calls = iter(states)
+ state = poll_until_done(
+ interval=0, timeout=5,
+ poll_fn=lambda: next(calls),
+ is_done=lambda s: s["status"] == "completed",
+ )
+ assert state["status"] == "completed"
+
+ def test_poll_timeout(self):
+ with pytest.raises(ApiError, match="超时"):
+ poll_until_done(
+ interval=0, timeout=0.01,
+ poll_fn=lambda: {"status": "processing"},
+ is_done=lambda s: False,
+ )
+
+
+# ---------------------------------------------------------------------------
+# api_client.py:request_json 错误路径(真实 urllib,坏 URL)
+# ---------------------------------------------------------------------------
+
+
+class TestDownloadResults:
+ """fill 默认同时下载标注版 + 纯净版;--clean 只下纯净版。"""
+
+ def test_default_downloads_both_versions(self, tmp_path, monkeypatch):
+ import llmfill
+
+ calls = []
+
+ def fake_download(base, key, path, out_dir, **kw):
+ calls.append(path)
+ suffix = "_clean.docx" if "clean=true" in path else "_processed.docx"
+ return Path(out_dir) / f"表单{suffix}"
+
+ monkeypatch.setattr(llmfill, "download_file", fake_download)
+ cfg = {"base_url": "https://x", "api_key": "aif_xxxx1234"}
+ targets = llmfill._download_batch_results(cfg, "b_1", Path(tmp_path))
+ assert [t.name for t in targets] == ["表单_processed.docx", "表单_clean.docx"]
+ assert calls == [
+ "/v1/documents/b_1/result",
+ "/v1/documents/b_1/result?clean=true",
+ ]
+
+ def test_clean_only_skips_annotated(self, tmp_path, monkeypatch):
+ import llmfill
+
+ calls = []
+
+ def fake_download(base, key, path, out_dir, **kw):
+ calls.append(path)
+ return Path(out_dir) / "表单_clean.docx"
+
+ monkeypatch.setattr(llmfill, "download_file", fake_download)
+ cfg = {"base_url": "https://x", "api_key": "aif_xxxx1234"}
+ targets = llmfill._download_batch_results(cfg, "b_1", Path(tmp_path), clean_only=True)
+ assert [t.name for t in targets] == ["表单_clean.docx"]
+ assert calls == ["/v1/documents/b_1/result?clean=true"]
+
+
+class TestRequestJsonNetwork:
+ def test_network_error(self):
+ with pytest.raises(ApiError) as ei:
+ api_client.request_json(
+ "http://127.0.0.1:1", "aif_xxxx1234", "GET", "/v1/knowledge-bases", timeout=2
+ )
+ assert ei.value.code == "NETWORK_ERROR"
+
+
+# ---------------------------------------------------------------------------
+# CLI:--json 输出与退出码(mock 网络)
+# ---------------------------------------------------------------------------
+
+
+class TestCli:
+ def _run(self, argv, monkeypatch, responses=None):
+ import llmfill
+
+ # mock request_json:按 (method, path) 返回预设响应
+ def fake_request(base, key, method, path, **kw):
+ for (m, p), resp in (responses or {}).items():
+ if method == m and path == p:
+ if isinstance(resp, ApiError):
+ raise resp
+ return resp
+ raise ApiError("NOT_MOCKED", f"{method} {path} 未 mock")
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ return llmfill.main(argv)
+
+ def test_whoami_json(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ rc = self._run(["--json", "whoami"], monkeypatch, responses={
+ ("GET", "/v1/account/whoami"): {"user_id": "u-1", "token_valid": True, "balance": 5.5},
+ })
+ out = json.loads(capsys.readouterr().out)
+ assert rc == 0
+ assert out["user_id"] == "u-1"
+ assert out["balance"] == 5.5
+
+ def test_fill_status_json(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ rc = self._run(["--json", "fill-status", "b_1"], monkeypatch, responses={
+ ("GET", "/v1/documents/b_1/status"): {
+ "batch_id": "b_1", "status": "completed", "progress": 100,
+ "completed_count": 2, "total_count": 2, "failed_count": 0,
+ },
+ })
+ out = json.loads(capsys.readouterr().out)
+ assert rc == 0
+ assert out["status"] == "completed"
+
+ def test_search_command_removed(self, tmp_path, monkeypatch):
+ """search 子命令已移除(纯后端接口不暴露),argparse 报无效命令。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ import llmfill
+ with pytest.raises(SystemExit):
+ llmfill.main(["search", "--kb", "k", "--query", "q"])
+
+ def test_kb_upload_polls_documents_parse_status(self, tmp_path, monkeypatch, capsys):
+ """kb upload 通过文档列表 parse_status 轮询(网关不暴露 /tasks)。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ import llmfill
+
+ # 上传前列表为空;上传后文档出现,前两次 parsing、第三次 completed
+ doc_lists = [
+ {"documents": []}, # 上传前快照
+ {"documents": [{"document_id": "d1", "filename": "手册.md", "parse_status": "parsing"}]},
+ {"documents": [{"document_id": "d1", "filename": "手册.md", "parse_status": "parsing"}]},
+ {"documents": [{"document_id": "d1", "filename": "手册.md", "parse_status": "completed"}]},
+ ]
+ calls = iter(doc_lists)
+
+ def fake_request(base, key, method, path, **kw):
+ if method == "GET" and path.endswith("/documents"):
+ return next(calls)
+ if method == "POST":
+ return {"task_id": "t-1", "status": "pending", "message": "Task accepted"}
+ raise ApiError("NOT_MOCKED", f"{method} {path} 未 mock")
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ f = tmp_path / "手册.md"
+ f.write_text("# x", encoding="utf-8")
+ rc = llmfill.main(["kb", "upload", "kb-1", str(f)])
+ out = capsys.readouterr().out
+ assert rc == 0
+ assert "入库完成" in out
+
+ def test_kb_upload_json_output(self, tmp_path, monkeypatch, capsys):
+ """kb upload --json 应输出机器可读结果(修复:此前 JSON 模式无任何输出)。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ import llmfill
+
+ doc_lists = iter([
+ {"documents": []},
+ {"documents": [{"document_id": "d1", "filename": "手册.md", "parse_status": "completed"}]},
+ ])
+
+ def fake_request(base, key, method, path, **kw):
+ if method == "GET" and path.endswith("/documents"):
+ return next(doc_lists)
+ if method == "POST":
+ return {"task_id": "t-1", "status": "pending"}
+ raise ApiError("NOT_MOCKED", f"{method} {path} 未 mock")
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ f = tmp_path / "手册.md"
+ f.write_text("# x", encoding="utf-8")
+ rc = llmfill.main(["--json", "kb", "upload", "kb-1", str(f)])
+ out = json.loads(capsys.readouterr().out)
+ assert rc == 0
+ assert out["kb_id"] == "kb-1"
+ assert out["uploaded"][0]["document_id"] == "d1"
+ assert out["uploaded"][0]["parse_status"] == "completed"
+ assert out["failed"] == []
+
+ def test_kb_upload_failed_doc_reported(self, tmp_path, monkeypatch, capsys):
+ """入库 parse_status=failed 时计入失败并汇总非零退出。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ import llmfill
+
+ doc_lists = iter([
+ {"documents": []},
+ {"documents": [{"document_id": "d1", "filename": "坏.pdf", "parse_status": "failed"}]},
+ ])
+
+ def fake_request(base, key, method, path, **kw):
+ if method == "GET" and path.endswith("/documents"):
+ return next(doc_lists)
+ if method == "POST":
+ return {"task_id": "t-1", "status": "pending"}
+ raise ApiError("NOT_MOCKED", f"{method} {path} 未 mock")
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ f = tmp_path / "坏.pdf"
+ f.write_bytes(b"%PDF-fake")
+ rc = llmfill.main(["kb", "upload", "kb-1", str(f)])
+ capsys.readouterr()
+ assert rc == 1
+
+ def test_api_error_exit_code(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ rc = self._run(["whoami"], monkeypatch, responses={
+ ("GET", "/v1/account/whoami"): ApiError("INVALID_TOKEN", "令牌无效", 401),
+ })
+ assert rc == 1
+
+ def test_missing_config_exit_code(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "none.json")
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ import llmfill
+ rc = llmfill.main(["whoami"])
+ assert rc == 2
+
+ def test_kb_ls_json(self, tmp_path, monkeypatch, capsys):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ rc = self._run(["--json", "kb", "ls"], monkeypatch, responses={
+ ("GET", "/v1/knowledge-bases"): {
+ "total": 1, "kbs": [{"kb_id": "kb-1", "name": "资料库", "document_count": 3}],
+ },
+ })
+ out = json.loads(capsys.readouterr().out)
+ assert rc == 0
+ assert out["kbs"][0]["kb_id"] == "kb-1"
+
+ def test_kb_rm_requires_yes_noninteractive(self, tmp_path, monkeypatch, capsys):
+ """非交互环境 kb rm 不加 --yes 应报 CONFIRM_REQUIRED,且不实际删除。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ import llmfill
+
+ calls = []
+
+ def fake_request(base, key, method, path, **kw):
+ calls.append((method, path))
+ return {"success": True}
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["kb", "rm", "kb-1"])
+ capsys.readouterr()
+ assert rc == 1
+ assert calls == [] # 未调用 DELETE
+
+ def test_kb_rm_with_yes_deletes(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setenv("LLMFILL_API_KEY", "aif_xxxx1234")
+ import llmfill
+
+ calls = []
+
+ def fake_request(base, key, method, path, **kw):
+ calls.append((method, path))
+ return {"success": True}
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["kb", "rm", "kb-1", "--yes"])
+ assert rc == 0
+ assert calls == [("DELETE", "/v1/knowledge-bases/kb-1")]
+
+ def test_config_custom_base_requires_approval_noninteractive(self, tmp_path, monkeypatch, capsys):
+ """非交互环境 config --base 自定义地址、不加 --allow-custom,应 fail-closed。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ import llmfill
+
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--base", "https://private.example", "--token", "aif_xxxx1234"])
+ capsys.readouterr()
+ assert rc == 1
+
+ def test_config_custom_base_allow_custom_persists(self, tmp_path, monkeypatch, capsys):
+ """--allow-custom 批准自定义地址并持久化 approved_origins。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ import llmfill
+
+ def fake_request(base, key, method, path, **kw):
+ return {"user_id": "u-1", "balance": 1.0}
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main([
+ "config", "--base", "https://private.example",
+ "--token", "aif_xxxx1234", "--allow-custom",
+ ])
+ capsys.readouterr()
+ assert rc == 0
+ cfg = config_mod.load_config()
+ assert cfg["base_url"] == "https://private.example"
+ assert "https://private.example" in cfg["approved_origins"]
+
+ def test_config_token_env_reads_variable(self, tmp_path, monkeypatch, capsys):
+ """--token-env 从指定环境变量读取令牌(推荐方式,不进 shell 历史)。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ monkeypatch.setenv("LLMFILL_API_TOKEN", "aif_from_env")
+ import llmfill
+
+ def fake_request(base, key, method, path, **kw):
+ assert key == "aif_from_env"
+ return {"user_id": "u-1", "balance": 1.0}
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--token-env", "LLMFILL_API_TOKEN"])
+ capsys.readouterr()
+ assert rc == 0
+ cfg = config_mod.load_config()
+ assert cfg["api_key"] == "aif_from_env"
+
+ def test_config_token_env_missing_raises(self, tmp_path, monkeypatch, capsys):
+ """--token-env 指向未设置的环境变量应报错,且令牌错误不落盘。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ monkeypatch.delenv("NOPE_TOKEN", raising=False)
+ import llmfill
+
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--token-env", "NOPE_TOKEN"])
+ capsys.readouterr()
+ assert rc == 1
+
+ def test_config_token_env_rejects_non_allowlisted(self, tmp_path, monkeypatch, capsys):
+ """--token-env 拒绝白名单外的变量名:防止误读无关服务的密钥。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ monkeypatch.setenv("GITHUB_TOKEN", "ghp_不是llmfill令牌")
+ import llmfill
+
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--token-env", "GITHUB_TOKEN"])
+ captured = capsys.readouterr()
+ assert rc == 1
+ assert "仅允许" in captured.out + captured.err
+ # 白名单外变量即使设置了也绝不能被读取/落盘
+ assert not (tmp_path / "c.json").exists()
+
+ def test_config_token_env_mask_placeholder_hint(self, tmp_path, monkeypatch, capsys):
+ """--token-env 收到掩码占位符(secret 未注入)时给出针对性提示。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ monkeypatch.setenv("LLMFILL_API_TOKEN", "***")
+ import llmfill
+
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--token-env", "LLMFILL_API_TOKEN"])
+ captured = capsys.readouterr()
+ assert rc == 1
+ assert "掩码" in captured.out + captured.err
+ assert not (tmp_path / "c.json").exists()
+
+ def test_config_token_env_secret_ref_hint(self, tmp_path, monkeypatch, capsys):
+ """--token-env 收到 secret 引用串(store:LLMFILL_API_TOKEN)时给出针对性提示。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ monkeypatch.setenv("LLMFILL_API_TOKEN", "store:LLMFILL_API_TOKEN")
+ import llmfill
+
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--token-env", "LLMFILL_API_TOKEN"])
+ captured = capsys.readouterr()
+ assert rc == 1
+ assert "引用" in captured.out + captured.err
+ assert not (tmp_path / "c.json").exists()
+
+ def test_config_token_env_rejects_wrong_format(self, tmp_path, monkeypatch, capsys):
+ """--token-env 的值不是 aif_ 格式时拒绝,不保存也不发送。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ monkeypatch.setenv("LLMFILL_API_TOKEN", "ghp_误指向的无关密钥")
+ import llmfill
+
+ called = []
+
+ def fake_request(*a, **kw):
+ called.append(a)
+ return {}
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--token-env", "LLMFILL_API_TOKEN"])
+ captured = capsys.readouterr()
+ assert rc == 1
+ # 格式错误:不发起远程请求、不落盘、错误信息不回显令牌值
+ assert not called
+ assert not (tmp_path / "c.json").exists()
+ assert "ghp_误指向的无关密钥" not in captured.out + captured.err
+
+ def test_config_verify_fail_does_not_persist_new_token(
+ self, tmp_path, monkeypatch, capsys
+ ):
+ """新令牌远程验证失败时不落盘(fail-closed),旧令牌仍保留。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ import llmfill
+ from api_client import ApiError
+
+ config_mod.save_config({"api_key": "aif_old_token_1"})
+
+ def fake_request(base, key, method, path, **kw):
+ raise ApiError("INVALID_TOKEN", "令牌无效")
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ monkeypatch.setattr(sys, "stdin", _FakeStdin())
+ rc = llmfill.main(["config", "--token", "aif_bad_but_format_ok"])
+ captured = capsys.readouterr()
+ assert rc == 0 # 验证失败是结果输出(ok 消息),不是崩溃
+ assert "未写入" in captured.out + captured.err
+ cfg = config_mod.load_config(require=False, check_approval=False)
+ # 候选新令牌未持久化,存量旧令牌不被覆盖
+ assert cfg["api_key"] == "aif_old_token_1"
+
+ def test_config_interactive_getpass_no_echo(self, tmp_path, monkeypatch, capsys):
+ """交互输入走 getpass(不回显):令牌值不得出现在任何输出。"""
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "c.json")
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
+ monkeypatch.delenv("LLMFILL_API_KEY", raising=False)
+ import llmfill
+
+ monkeypatch.setattr(sys, "stdin", _TtyStdin())
+ monkeypatch.setattr(llmfill.getpass, "getpass", lambda *a, **k: "aif_secret")
+
+ def fake_request(base, key, method, path, **kw):
+ return {"user_id": "u-1"}
+
+ monkeypatch.setattr(llmfill, "request_json", fake_request)
+ rc = llmfill.main(["config"])
+ captured = capsys.readouterr()
+ assert rc == 0
+ # 令牌明文不得回显到 stdout/stderr(防录屏/会话日志截获)
+ assert "aif_secret" not in captured.out + captured.err
+ cfg = config_mod.load_config()
+ assert cfg["api_key"] == "aif_secret"