diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 576ac05..8e2bfb0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -51,6 +51,7 @@ export default defineConfig({ { text: '定时任务与积分', link: '/guide/loomy/scheduled-tasks' }, { text: '远程控制', link: '/guide/loomy/remote-control' }, { text: '办公 CLI 集成', link: '/guide/loomy/office-cli' }, + { text: '无界面周报自动化', link: '/guide/loomy/headless-office-automation' }, { text: '典型工作场景', link: '/guide/loomy/scenarios' }, { text: '实战:打造个人 AI 搭子', link: '/guide/loomy/personal-companion' }, { text: '常见问题', link: '/guide/loomy/faq' } @@ -102,6 +103,7 @@ export default defineConfig({ { text: 'Scheduled Tasks', link: '/en/guide/loomy/scheduled-tasks' }, { text: 'Remote Control', link: '/en/guide/loomy/remote-control' }, { text: 'Office CLI', link: '/en/guide/loomy/office-cli' }, + { text: 'Headless Weekly Reports', link: '/en/guide/loomy/headless-office-automation' }, { text: 'Scenarios', link: '/en/guide/loomy/scenarios' }, { text: 'Build a Personal AI Companion', link: '/en/guide/loomy/personal-companion' }, { text: 'FAQ', link: '/en/guide/loomy/faq' } diff --git a/docs/en/guide/loomy/headless-office-automation.md b/docs/en/guide/loomy/headless-office-automation.md new file mode 100644 index 0000000..f9089bf --- /dev/null +++ b/docs/en/guide/loomy/headless-office-automation.md @@ -0,0 +1,158 @@ +# Headless weekly reports: cloud planning + local execution + +This tutorial builds a reproducible workflow: AstronClaw decomposes the goal “create this week's project report from `data.csv`,” then local Loomy calls a narrowly scoped MCP tool to create and verify a `.docx`. Word never opens, and the local directory is not exposed to the cloud. + +> **Capability boundary:** The public documentation does not promise a dedicated API through which AstronClaw directly drives Loomy. This tutorial uses two documented handoff paths: copy the structured plan manually, or send it through a message channel configured under [Remote Control](/en/guide/loomy/remote-control) to Loomy running locally in the background. Do not interpret the example as an undocumented cloud-to-desktop API. + +## Workflow + +| Stage | Actor | Output | +|---|---|---| +| Plan | AstronClaw | A JSON plan containing only filenames, date, and title | +| Handoff | You or a remote-control channel | The plan reaches local Loomy | +| Execute | Loomy + local MCP | Read an allowed CSV and create a DOCX | +| Verify | Local MCP | Return row count, size, and SHA-256; preserve the old file on failure | + +The example does not expose arbitrary terminal or filesystem access. Its MCP server has only two tools—inspect the CSV and build the report—and every path must remain under `REPORT_WORKSPACE`. + +## 1. Prepare the example + +Download or clone this repository and enter [`examples/headless-weekly-report`](https://github.com/iflytek/astronclaw-tutorial/tree/main/examples/headless-weekly-report): + +```powershell +cd examples\headless-weekly-report +python -m venv .venv +.\.venv\Scripts\python -m pip install -r requirements.txt +``` + +On macOS or Linux, replace the last command with: + +```bash +.venv/bin/python -m pip install -r requirements.txt +``` + +The example uses the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 2.x and `python-docx`. Its input is `workspace/data.csv`, with these required columns: + +```text +project,status,progress,next_step,owner +``` + +Test the local build path before involving Loomy: + +```powershell +.\.venv\Scripts\python report_builder.py ` + --workspace workspace ` + --source data.csv ` + --output weekly-report-2026-09-04.docx ` + --week-ending 2026-09-04 ` + --title "Project weekly report" +``` + +On macOS or Linux, use backslash line continuations and `.venv/bin/python`. A successful run returns metadata like: + +```json +{ + "output": "weekly-report-2026-09-04.docx", + "week_ending": "2026-09-04", + "row_count": 3, + "size_bytes": 37142, + "sha256": "..." +} +``` + +## 2. Connect the tool to Loomy + +In Loomy, open **Settings → Toolbox** and import an external custom MCP configuration. Replace every path below with an **absolute path**. + +Windows: + +```json +{ + "mcpServers": { + "headless-weekly-report": { + "command": "D:\\path\\to\\headless-weekly-report\\.venv\\Scripts\\mcp.exe", + "args": [ + "run", + "D:\\path\\to\\headless-weekly-report\\mcp_server.py" + ], + "env": { + "REPORT_WORKSPACE": "D:\\path\\to\\headless-weekly-report\\workspace" + } + } + } +} +``` + +macOS/Linux: + +```json +{ + "mcpServers": { + "headless-weekly-report": { + "command": "/absolute/path/headless-weekly-report/.venv/bin/mcp", + "args": ["run", "/absolute/path/headless-weekly-report/mcp_server.py"], + "env": { + "REPORT_WORKSPACE": "/absolute/path/headless-weekly-report/workspace" + } + } + } +} +``` + +Save and restart or refresh the tool, then set both new tool permissions to **Ask**. See [Toolbox and Skill System](/en/guide/loomy/toolbox). + +## 3. Ask AstronClaw for an execution plan + +Send this to AstronClaw: + +> Turn “create this week's project report from data.csv” into a JSON plan for a desktop executor. Use relative filenames only. The exact fields must be source_csv, output_docx, week_ending, and title. Do not generate shell commands, file contents, or credentials. + +Inspect the answer before handing it to Loomy. For example: + +```json +{ + "source_csv": "data.csv", + "output_docx": "weekly-report-2026-09-04.docx", + "week_ending": "2026-09-04", + "title": "Project weekly report" +} +``` + +If a message channel triggers the task remotely, Loomy must remain running on the local computer. Do not mark the task complete when that computer is offline. + +## 4. Execute and confirm in Loomy + +Send the JSON plan to Loomy with this instruction: + +> Call `inspect_weekly_report_source` first, then tell me the row count and status distribution. After I confirm, call `build_weekly_report`. If the target exists, ask me before setting overwrite. Return output, size_bytes, and sha256 when done. Do not claim that the document was opened or visually inspected. + +The expected sequence is: + +1. Loomy inspects `data.csv` without reading outside the allowed directory. +2. You confirm the summary and output filename. +3. The tool writes a temporary DOCX, validates its ZIP/Open XML structure, and atomically replaces the target only after validation. +4. Loomy returns verifiable file metadata. Open the final document in a file manager if you want to inspect layout. + +## 5. Error-recovery loop + +- **CSV not found:** place it under `REPORT_WORKSPACE`, keep using a relative filename, and inspect again. +- **Required column missing:** fix the named header; do not ask the model to guess column semantics. +- **Target exists:** choose a date-stamped filename, or set `overwrite=true` only after explicit confirmation. +- **DOCX build fails:** the old file remains intact and the temporary file is removed; fix the input or dependency and retry. +- **MCP unavailable:** confirm that every configured path is absolute, then run `.venv\Scripts\mcp.exe --help` (macOS/Linux: `.venv/bin/mcp --help`) in a terminal. + +## Security checklist + +- Create a dedicated report workspace; never authorize an entire home directory or disk. +- Set tool permissions to **Ask**, and require a second confirmation before overwriting output. +- Keep passwords, tokens, identity numbers, and unnecessary personal data out of the CSV. +- The example does not invoke a shell, accept command strings, or access the network. It rejects absolute and `..` escape paths. +- SHA-256 proves that the generated file has not changed since the tool returned; it does not prove the report is factually correct. A responsible owner should still review it before publication. + +## Reproduce the tests + +```powershell +.\.venv\Scripts\python -m unittest -v test_report_builder.py +``` + +The tests cover the CSV contract, directory traversal, DOCX package validation, and explicit overwrite protection. Each part of the plan–execute–verify loop therefore has an observable result instead of relying on a success claim in chat. diff --git a/docs/guide/loomy/headless-office-automation.md b/docs/guide/loomy/headless-office-automation.md new file mode 100644 index 0000000..9f0b9fa --- /dev/null +++ b/docs/guide/loomy/headless-office-automation.md @@ -0,0 +1,158 @@ +# 无界面周报自动化:云端规划 + 本地执行 + +这篇教程完成一个可复现的流程:让 AstronClaw 负责拆解“根据 `data.csv` 生成本周项目报告”的目标,再让本地 Loomy 通过一个权限收敛的 MCP 工具生成并校验 `.docx`。整个执行过程不打开 Word,也不向云端暴露本地目录。 + +> **能力边界**:当前公开文档没有承诺 AstronClaw 可通过专用 API 直接驱动 Loomy。本文使用两种已有入口传递任务:手动复制结构化计划,或通过[远程控制](/guide/loomy/remote-control)中已配置的消息渠道把计划发给保持后台运行的 Loomy。不要把示例理解为未公开的云桌面直连 API。 + +## 工作流 + +| 阶段 | 执行者 | 产物 | +|---|---|---| +| 规划 | AstronClaw | 只包含文件名、截止日期和标题的 JSON 计划 | +| 交接 | 你或远程控制渠道 | 将计划发送给本地 Loomy | +| 执行 | Loomy + 本地 MCP | 读取授权目录中的 CSV,生成 DOCX | +| 验证 | 本地 MCP | 返回行数、文件大小和 SHA-256;失败时不替换旧文件 | + +示例没有提供任意终端执行或任意文件读写。MCP 服务只有“检查 CSV”和“生成周报”两个工具,所有路径必须位于 `REPORT_WORKSPACE` 内。 + +## 1. 准备示例 + +下载或克隆本仓库,进入 [`examples/headless-weekly-report`](https://github.com/iflytek/astronclaw-tutorial/tree/main/examples/headless-weekly-report): + +```powershell +cd examples\headless-weekly-report +python -m venv .venv +.\.venv\Scripts\python -m pip install -r requirements.txt +``` + +macOS/Linux 将最后一条命令换成: + +```bash +.venv/bin/python -m pip install -r requirements.txt +``` + +示例依赖[官方 MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 2.x 和 `python-docx`。输入文件是 `workspace/data.csv`,必需列为: + +```text +project,status,progress,next_step,owner +``` + +先绕过 Loomy 直接验证本地构建链路: + +```powershell +.\.venv\Scripts\python report_builder.py ` + --workspace workspace ` + --source data.csv ` + --output weekly-report-2026-09-04.docx ` + --week-ending 2026-09-04 ` + --title "项目周报" +``` + +macOS/Linux 使用反斜杠续行和 `.venv/bin/python`。成功时会输出类似: + +```json +{ + "output": "weekly-report-2026-09-04.docx", + "week_ending": "2026-09-04", + "row_count": 3, + "size_bytes": 37142, + "sha256": "..." +} +``` + +## 2. 接入 Loomy 工具箱 + +进入 Loomy 的「设置」→「工具箱」,导入外部自定义 MCP 配置。把下面所有路径改成你的**绝对路径**。 + +Windows: + +```json +{ + "mcpServers": { + "headless-weekly-report": { + "command": "D:\\path\\to\\headless-weekly-report\\.venv\\Scripts\\mcp.exe", + "args": [ + "run", + "D:\\path\\to\\headless-weekly-report\\mcp_server.py" + ], + "env": { + "REPORT_WORKSPACE": "D:\\path\\to\\headless-weekly-report\\workspace" + } + } + } +} +``` + +macOS/Linux: + +```json +{ + "mcpServers": { + "headless-weekly-report": { + "command": "/absolute/path/headless-weekly-report/.venv/bin/mcp", + "args": ["run", "/absolute/path/headless-weekly-report/mcp_server.py"], + "env": { + "REPORT_WORKSPACE": "/absolute/path/headless-weekly-report/workspace" + } + } + } +} +``` + +保存后重启或刷新该工具,并把两个新工具的权限设为「询问」。详见[工具箱与技能系统](/guide/loomy/toolbox)。 + +## 3. 让 AstronClaw 生成执行计划 + +在 AstronClaw 中发送: + +> 把“根据 data.csv 生成本周项目周报”拆成一份交给桌面执行器的 JSON 计划。只允许使用相对文件名;输出字段必须是 source_csv、output_docx、week_ending、title。不要生成 shell 命令,不要包含文件内容或凭据。 + +检查返回结果后再传给 Loomy,例如: + +```json +{ + "source_csv": "data.csv", + "output_docx": "weekly-report-2026-09-04.docx", + "week_ending": "2026-09-04", + "title": "项目周报" +} +``` + +如果使用消息渠道远程触发,Loomy 必须在本地保持后台运行;本地设备离线时不要把任务标记为完成。 + +## 4. 在 Loomy 中执行与确认 + +将 JSON 计划发送给 Loomy,并补充这段指令: + +> 先调用 `inspect_weekly_report_source` 检查输入结构并告诉我行数和状态分布。确认无误后,调用 `build_weekly_report`。如果目标文件已存在,先询问我,不得自动设置 overwrite。完成后返回 output、size_bytes 和 sha256;不要声称已经打开或人工检查 Word 文件。 + +正常链路是: + +1. Loomy 检查 `data.csv`,但不读取授权目录外的文件。 +2. 你确认摘要和目标文件名。 +3. 工具先在同目录写临时 DOCX,校验 ZIP/Open XML 结构后再原子替换目标。 +4. Loomy 返回可核对的文件元数据。你可在文件管理器中打开最终文档抽查排版。 + +## 5. 错误处理闭环 + +- **找不到 CSV**:把文件放入 `REPORT_WORKSPACE`,仍只传相对文件名,然后重新检查。 +- **缺少列**:按错误列名修改表头;不要让模型猜列的含义。 +- **目标已存在**:换一个带日期的文件名,或在你明确确认后设置 `overwrite=true`。 +- **DOCX 生成失败**:旧文件不会被替换,临时文件会被清理;修复输入或依赖后重试。 +- **MCP 不可用**:检查配置是否使用绝对路径,并在终端运行 `.venv\Scripts\mcp.exe --help`(macOS/Linux 为 `.venv/bin/mcp --help`)。 + +## 安全清单 + +- 为周报单独创建工作目录,不要授权整个用户目录或磁盘。 +- 工具权限设为「询问」,覆盖文件必须二次确认。 +- CSV 中不要存密码、Token、身份证号或不需要进入周报的个人信息。 +- 本示例不调用 shell、不接受命令字符串、不访问网络,并拒绝绝对路径和 `..` 越界路径。 +- SHA-256 证明本次生成后文件未变化,但不代表内容真实;发布前仍需负责人审核数据与措辞。 + +## 复现测试 + +```powershell +.\.venv\Scripts\python -m unittest -v test_report_builder.py +``` + +测试覆盖 CSV 契约、目录越界、DOCX 结构校验和显式覆盖保护。这样,“规划—执行—验证”每一步都有可观察结果,而不是只根据对话文本判断任务成功。 diff --git a/docs/public/docs-index.json b/docs/public/docs-index.json index ea2e99e..16a331b 100644 --- a/docs/public/docs-index.json +++ b/docs/public/docs-index.json @@ -454,6 +454,51 @@ "heading": "适合什么样的用户?", "content": "Loomy 适合需要频繁处理信息整理、内容创作、文档协作、任务跟进和跨工具执行的人群。比如:\n* 自媒体运营\n* 办公室白领\n* 内容团队\n* 小型业务团队\n* 电商从业者\n* 希望把重复流程交给 AI 协助完成的个人用户\n\n如果你的工作中经常需要在聊天、邮件、文档、网页和待办之间反复切换,Loomy 会更容易发挥价值。" }, + { + "lang": "zh", + "heading": "无界面周报自动化:云端规划 + 本地执行", + "content": "这篇教程完成一个可复现的流程:让 AstronClaw 负责拆解“根据 `data.csv` 生成本周项目报告”的目标,再让本地 Loomy 通过一个权限收敛的 MCP 工具生成并校验 `.docx`。整个执行过程不打开 Word,也不向云端暴露本地目录。\n\n> **能力边界**:当前公开文档没有承诺 AstronClaw 可通过专用 API 直接驱动 Loomy。本文使用两种已有入口传递任务:手动复制结构化计划,或通过[远程控制](/guide/loomy/remote-control)中已配置的消息渠道把计划发给保持后台运行的 Loomy。不要把示例理解为未公开的云桌面直连 API。" + }, + { + "lang": "zh", + "heading": "工作流", + "content": "| 阶段 | 执行者 | 产物 |\n|---|---|---|\n| 规划 | AstronClaw | 只包含文件名、截止日期和标题的 JSON 计划 |\n| 交接 | 你或远程控制渠道 | 将计划发送给本地 Loomy |\n| 执行 | Loomy + 本地 MCP | 读取授权目录中的 CSV,生成 DOCX |\n| 验证 | 本地 MCP | 返回行数、文件大小和 SHA-256;失败时不替换旧文件 |\n\n示例没有提供任意终端执行或任意文件读写。MCP 服务只有“检查 CSV”和“生成周报”两个工具,所有路径必须位于 `REPORT_WORKSPACE` 内。" + }, + { + "lang": "zh", + "heading": "1. 准备示例", + "content": "下载或克隆本仓库,进入 [`examples/headless-weekly-report`](https://github.com/iflytek/astronclaw-tutorial/tree/main/examples/headless-weekly-report):\n\n```powershell\ncd examples\\headless-weekly-report\npython -m venv .venv\n.\\.venv\\Scripts\\python -m pip install -r requirements.txt\n```\n\nmacOS/Linux 将最后一条命令换成:\n\n```bash\n.venv/bin/python -m pip install -r requirements.txt\n```\n\n示例依赖[官方 MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 2.x 和 `python-docx`。输入文件是 `workspace/data.csv`,必需列为:\n\n```text\nproject,status,progress,next_step,owner\n```\n\n先绕过 Loomy 直接验证本地构建链路:\n\n```powershell\n.\\.venv\\Scripts\\python report_builder.py `\n --workspace workspace `\n --source data.csv `\n --output weekly-report-2026-09-04.docx `\n --week-ending 2026-09-04 `\n --title \"项目周报\"\n```\n\nmacOS/Linux 使用反斜杠续行和 `.venv/bin/python`。成功时会输出类似:\n\n```json\n{\n \"output\": \"weekly-report-2026-09-04.docx\",\n \"week_ending\": \"2026-09-04\",\n \"row_count\": 3,\n \"size_bytes\": 37142,\n \"sha256\": \"...\"\n}\n```" + }, + { + "lang": "zh", + "heading": "2. 接入 Loomy 工具箱", + "content": "进入 Loomy 的「设置」→「工具箱」,导入外部自定义 MCP 配置。把下面所有路径改成你的**绝对路径**。\n\nWindows:\n\n```json\n{\n \"mcpServers\": {\n \"headless-weekly-report\": {\n \"command\": \"D:\\\\path\\\\to\\\\headless-weekly-report\\\\.venv\\\\Scripts\\\\mcp.exe\",\n \"args\": [\n \"run\",\n \"D:\\\\path\\\\to\\\\headless-weekly-report\\\\mcp_server.py\"\n ],\n \"env\": {\n \"REPORT_WORKSPACE\": \"D:\\\\path\\\\to\\\\headless-weekly-report\\\\workspace\"\n }\n }\n }\n}\n```\n\nmacOS/Linux:\n\n```json\n{\n \"mcpServers\": {\n \"headless-weekly-report\": {\n \"command\": \"/absolute/path/headless-weekly-report/.venv/bin/mcp\",\n \"args\": [\"run\", \"/absolute/path/headless-weekly-report/mcp_server.py\"],\n \"env\": {\n \"REPORT_WORKSPACE\": \"/absolute/path/headless-weekly-report/workspace\"\n }\n }\n }\n}\n```\n\n保存后重启或刷新该工具,并把两个新工具的权限设为「询问」。详见[工具箱与技能系统](/guide/loomy/toolbox)。" + }, + { + "lang": "zh", + "heading": "3. 让 AstronClaw 生成执行计划", + "content": "在 AstronClaw 中发送:\n\n> 把“根据 data.csv 生成本周项目周报”拆成一份交给桌面执行器的 JSON 计划。只允许使用相对文件名;输出字段必须是 source_csv、output_docx、week_ending、title。不要生成 shell 命令,不要包含文件内容或凭据。\n\n检查返回结果后再传给 Loomy,例如:\n\n```json\n{\n \"source_csv\": \"data.csv\",\n \"output_docx\": \"weekly-report-2026-09-04.docx\",\n \"week_ending\": \"2026-09-04\",\n \"title\": \"项目周报\"\n}\n```\n\n如果使用消息渠道远程触发,Loomy 必须在本地保持后台运行;本地设备离线时不要把任务标记为完成。" + }, + { + "lang": "zh", + "heading": "4. 在 Loomy 中执行与确认", + "content": "将 JSON 计划发送给 Loomy,并补充这段指令:\n\n> 先调用 `inspect_weekly_report_source` 检查输入结构并告诉我行数和状态分布。确认无误后,调用 `build_weekly_report`。如果目标文件已存在,先询问我,不得自动设置 overwrite。完成后返回 output、size_bytes 和 sha256;不要声称已经打开或人工检查 Word 文件。\n\n正常链路是:\n\n1. Loomy 检查 `data.csv`,但不读取授权目录外的文件。\n2. 你确认摘要和目标文件名。\n3. 工具先在同目录写临时 DOCX,校验 ZIP/Open XML 结构后再原子替换目标。\n4. Loomy 返回可核对的文件元数据。你可在文件管理器中打开最终文档抽查排版。" + }, + { + "lang": "zh", + "heading": "5. 错误处理闭环", + "content": "- **找不到 CSV**:把文件放入 `REPORT_WORKSPACE`,仍只传相对文件名,然后重新检查。\n- **缺少列**:按错误列名修改表头;不要让模型猜列的含义。\n- **目标已存在**:换一个带日期的文件名,或在你明确确认后设置 `overwrite=true`。\n- **DOCX 生成失败**:旧文件不会被替换,临时文件会被清理;修复输入或依赖后重试。\n- **MCP 不可用**:检查配置是否使用绝对路径,并在终端运行 `.venv\\Scripts\\mcp.exe --help`(macOS/Linux 为 `.venv/bin/mcp --help`)。" + }, + { + "lang": "zh", + "heading": "安全清单", + "content": "- 为周报单独创建工作目录,不要授权整个用户目录或磁盘。\n- 工具权限设为「询问」,覆盖文件必须二次确认。\n- CSV 中不要存密码、Token、身份证号或不需要进入周报的个人信息。\n- 本示例不调用 shell、不接受命令字符串、不访问网络,并拒绝绝对路径和 `..` 越界路径。\n- SHA-256 证明本次生成后文件未变化,但不代表内容真实;发布前仍需负责人审核数据与措辞。" + }, + { + "lang": "zh", + "heading": "复现测试", + "content": "```powershell\n.\\.venv\\Scripts\\python -m unittest -v test_report_builder.py\n```\n\n测试覆盖 CSV 契约、目录越界、DOCX 结构校验和显式覆盖保护。这样,“规划—执行—验证”每一步都有可观察结果,而不是只根据对话文本判断任务成功。" + }, { "lang": "zh", "heading": "介绍", @@ -1484,6 +1529,51 @@ "heading": "What kind of users is it suitable for?", "content": "Loomy is suitable for people who need to frequently handle information organization, content creation, document collaboration, task follow-up, and cross-tool execution. For example:\n* Self-media operations\n* Office white-collar workers\n* Content teams\n* Small business teams\n* E-commerce practitioners\n* Individual users who hope to hand over repetitive processes to AI for assistance\n\nIf your work frequently requires switching back and forth between chats, emails, documents, web pages, and to-dos, Loomy can help you work more efficiently." }, + { + "lang": "en", + "heading": "Headless weekly reports: cloud planning + local execution", + "content": "This tutorial builds a reproducible workflow: AstronClaw decomposes the goal “create this week's project report from `data.csv`,” then local Loomy calls a narrowly scoped MCP tool to create and verify a `.docx`. Word never opens, and the local directory is not exposed to the cloud.\n\n> **Capability boundary:** The public documentation does not promise a dedicated API through which AstronClaw directly drives Loomy. This tutorial uses two documented handoff paths: copy the structured plan manually, or send it through a message channel configured under [Remote Control](/en/guide/loomy/remote-control) to Loomy running locally in the background. Do not interpret the example as an undocumented cloud-to-desktop API." + }, + { + "lang": "en", + "heading": "Workflow", + "content": "| Stage | Actor | Output |\n|---|---|---|\n| Plan | AstronClaw | A JSON plan containing only filenames, date, and title |\n| Handoff | You or a remote-control channel | The plan reaches local Loomy |\n| Execute | Loomy + local MCP | Read an allowed CSV and create a DOCX |\n| Verify | Local MCP | Return row count, size, and SHA-256; preserve the old file on failure |\n\nThe example does not expose arbitrary terminal or filesystem access. Its MCP server has only two tools—inspect the CSV and build the report—and every path must remain under `REPORT_WORKSPACE`." + }, + { + "lang": "en", + "heading": "1. Prepare the example", + "content": "Download or clone this repository and enter [`examples/headless-weekly-report`](https://github.com/iflytek/astronclaw-tutorial/tree/main/examples/headless-weekly-report):\n\n```powershell\ncd examples\\headless-weekly-report\npython -m venv .venv\n.\\.venv\\Scripts\\python -m pip install -r requirements.txt\n```\n\nOn macOS or Linux, replace the last command with:\n\n```bash\n.venv/bin/python -m pip install -r requirements.txt\n```\n\nThe example uses the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 2.x and `python-docx`. Its input is `workspace/data.csv`, with these required columns:\n\n```text\nproject,status,progress,next_step,owner\n```\n\nTest the local build path before involving Loomy:\n\n```powershell\n.\\.venv\\Scripts\\python report_builder.py `\n --workspace workspace `\n --source data.csv `\n --output weekly-report-2026-09-04.docx `\n --week-ending 2026-09-04 `\n --title \"Project weekly report\"\n```\n\nOn macOS or Linux, use backslash line continuations and `.venv/bin/python`. A successful run returns metadata like:\n\n```json\n{\n \"output\": \"weekly-report-2026-09-04.docx\",\n \"week_ending\": \"2026-09-04\",\n \"row_count\": 3,\n \"size_bytes\": 37142,\n \"sha256\": \"...\"\n}\n```" + }, + { + "lang": "en", + "heading": "2. Connect the tool to Loomy", + "content": "In Loomy, open **Settings → Toolbox** and import an external custom MCP configuration. Replace every path below with an **absolute path**.\n\nWindows:\n\n```json\n{\n \"mcpServers\": {\n \"headless-weekly-report\": {\n \"command\": \"D:\\\\path\\\\to\\\\headless-weekly-report\\\\.venv\\\\Scripts\\\\mcp.exe\",\n \"args\": [\n \"run\",\n \"D:\\\\path\\\\to\\\\headless-weekly-report\\\\mcp_server.py\"\n ],\n \"env\": {\n \"REPORT_WORKSPACE\": \"D:\\\\path\\\\to\\\\headless-weekly-report\\\\workspace\"\n }\n }\n }\n}\n```\n\nmacOS/Linux:\n\n```json\n{\n \"mcpServers\": {\n \"headless-weekly-report\": {\n \"command\": \"/absolute/path/headless-weekly-report/.venv/bin/mcp\",\n \"args\": [\"run\", \"/absolute/path/headless-weekly-report/mcp_server.py\"],\n \"env\": {\n \"REPORT_WORKSPACE\": \"/absolute/path/headless-weekly-report/workspace\"\n }\n }\n }\n}\n```\n\nSave and restart or refresh the tool, then set both new tool permissions to **Ask**. See [Toolbox and Skill System](/en/guide/loomy/toolbox)." + }, + { + "lang": "en", + "heading": "3. Ask AstronClaw for an execution plan", + "content": "Send this to AstronClaw:\n\n> Turn “create this week's project report from data.csv” into a JSON plan for a desktop executor. Use relative filenames only. The exact fields must be source_csv, output_docx, week_ending, and title. Do not generate shell commands, file contents, or credentials.\n\nInspect the answer before handing it to Loomy. For example:\n\n```json\n{\n \"source_csv\": \"data.csv\",\n \"output_docx\": \"weekly-report-2026-09-04.docx\",\n \"week_ending\": \"2026-09-04\",\n \"title\": \"Project weekly report\"\n}\n```\n\nIf a message channel triggers the task remotely, Loomy must remain running on the local computer. Do not mark the task complete when that computer is offline." + }, + { + "lang": "en", + "heading": "4. Execute and confirm in Loomy", + "content": "Send the JSON plan to Loomy with this instruction:\n\n> Call `inspect_weekly_report_source` first, then tell me the row count and status distribution. After I confirm, call `build_weekly_report`. If the target exists, ask me before setting overwrite. Return output, size_bytes, and sha256 when done. Do not claim that the document was opened or visually inspected.\n\nThe expected sequence is:\n\n1. Loomy inspects `data.csv` without reading outside the allowed directory.\n2. You confirm the summary and output filename.\n3. The tool writes a temporary DOCX, validates its ZIP/Open XML structure, and atomically replaces the target only after validation.\n4. Loomy returns verifiable file metadata. Open the final document in a file manager if you want to inspect layout." + }, + { + "lang": "en", + "heading": "5. Error-recovery loop", + "content": "- **CSV not found:** place it under `REPORT_WORKSPACE`, keep using a relative filename, and inspect again.\n- **Required column missing:** fix the named header; do not ask the model to guess column semantics.\n- **Target exists:** choose a date-stamped filename, or set `overwrite=true` only after explicit confirmation.\n- **DOCX build fails:** the old file remains intact and the temporary file is removed; fix the input or dependency and retry.\n- **MCP unavailable:** confirm that every configured path is absolute, then run `.venv\\Scripts\\mcp.exe --help` (macOS/Linux: `.venv/bin/mcp --help`) in a terminal." + }, + { + "lang": "en", + "heading": "Security checklist", + "content": "- Create a dedicated report workspace; never authorize an entire home directory or disk.\n- Set tool permissions to **Ask**, and require a second confirmation before overwriting output.\n- Keep passwords, tokens, identity numbers, and unnecessary personal data out of the CSV.\n- The example does not invoke a shell, accept command strings, or access the network. It rejects absolute and `..` escape paths.\n- SHA-256 proves that the generated file has not changed since the tool returned; it does not prove the report is factually correct. A responsible owner should still review it before publication." + }, + { + "lang": "en", + "heading": "Reproduce the tests", + "content": "```powershell\n.\\.venv\\Scripts\\python -m unittest -v test_report_builder.py\n```\n\nThe tests cover the CSV contract, directory traversal, DOCX package validation, and explicit overwrite protection. Each part of the plan–execute–verify loop therefore has an observable result instead of relying on a success claim in chat." + }, { "lang": "en", "heading": "Loomy: Your Desktop Companion", diff --git a/examples/headless-weekly-report/.gitignore b/examples/headless-weekly-report/.gitignore new file mode 100644 index 0000000..be248d5 --- /dev/null +++ b/examples/headless-weekly-report/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +workspace/*.docx diff --git a/examples/headless-weekly-report/README.md b/examples/headless-weekly-report/README.md new file mode 100644 index 0000000..73aa27f --- /dev/null +++ b/examples/headless-weekly-report/README.md @@ -0,0 +1,28 @@ +# Headless weekly report example + +This example gives Loomy one narrow local MCP tool instead of unrestricted +shell access. It reads a CSV and writes a verified DOCX only inside the +directory configured by `REPORT_WORKSPACE`. + +## Run locally + +```powershell +python -m venv .venv +.\.venv\Scripts\python -m pip install -r requirements.txt +.\.venv\Scripts\python report_builder.py --workspace workspace --source data.csv --output weekly-report.docx --week-ending 2026-09-04 +.\.venv\Scripts\python -m unittest -v test_report_builder.py +``` + +On macOS or Linux, replace `.\.venv\Scripts\python` with +`.venv/bin/python`. + +## Connect to Loomy + +Import a standard MCP configuration in **Settings → Toolbox**. Use absolute +paths for both the MCP executable and `mcp_server.py`, and set +`REPORT_WORKSPACE` to the absolute `workspace` directory. The bilingual +tutorial pages contain complete Windows and macOS/Linux examples. + +The official MCP Python SDK uses stdio for this local server. The server must +not print application logs to stdout because stdout carries MCP protocol +messages. diff --git a/examples/headless-weekly-report/mcp_server.py b/examples/headless-weekly-report/mcp_server.py new file mode 100644 index 0000000..e4534ed --- /dev/null +++ b/examples/headless-weekly-report/mcp_server.py @@ -0,0 +1,50 @@ +"""Narrow MCP server for the headless weekly-report example.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from mcp.server import MCPServer +from report_builder import build_report, inspect_source + +mcp = MCPServer( + "Headless weekly report", + instructions=( + "Inspect the CSV first. Ask the user before replacing an existing DOCX, " + "then build and report the returned SHA-256 verification value." + ), +) + + +def _workspace() -> Path: + configured = os.environ.get("REPORT_WORKSPACE", "").strip() + if not configured: + raise RuntimeError("REPORT_WORKSPACE must be an absolute, dedicated directory") + workspace = Path(configured).expanduser() + if not workspace.is_absolute(): + raise RuntimeError("REPORT_WORKSPACE must be absolute") + if not workspace.is_dir(): + raise RuntimeError("REPORT_WORKSPACE must already exist and be a directory") + return workspace + + +@mcp.tool() +def inspect_weekly_report_source(source_csv: str = "data.csv") -> dict[str, Any]: + """Inspect the allowed CSV and return row, status, owner, and schema details.""" + return inspect_source(_workspace(), source_csv) + + +@mcp.tool() +def build_weekly_report( + output_docx: str, + week_ending: str, + source_csv: str = "data.csv", + title: str = "Weekly project report", + overwrite: bool = False, +) -> dict[str, Any]: + """Build and verify a DOCX entirely inside REPORT_WORKSPACE.""" + return build_report( + _workspace(), source_csv, output_docx, week_ending, title, overwrite + ) diff --git a/examples/headless-weekly-report/report_builder.py b/examples/headless-weekly-report/report_builder.py new file mode 100644 index 0000000..55200a8 --- /dev/null +++ b/examples/headless-weekly-report/report_builder.py @@ -0,0 +1,218 @@ +"""Build and verify a weekly-report DOCX inside an explicitly allowed workspace.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import tempfile +import zipfile +from collections import Counter +from datetime import date +from pathlib import Path +from typing import Any + +REQUIRED_HEADERS = ("project", "status", "progress", "next_step", "owner") + + +def _workspace_path(workspace: Path, relative_path: str, suffix: str) -> Path: + root = workspace.expanduser().resolve() + if not relative_path or Path(relative_path).is_absolute(): + raise ValueError("Use a non-empty path relative to REPORT_WORKSPACE") + candidate = (root / relative_path).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise ValueError("The requested path escapes REPORT_WORKSPACE") from exc + if candidate.suffix.lower() != suffix: + raise ValueError(f"Expected a {suffix} file: {relative_path}") + return candidate + + +def _load_rows(source: Path) -> list[dict[str, str]]: + if not source.is_file(): + raise FileNotFoundError(f"CSV source not found: {source.name}") + with source.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + headers = tuple(reader.fieldnames or ()) + missing = [name for name in REQUIRED_HEADERS if name not in headers] + if missing: + raise ValueError("CSV is missing required columns: " + ", ".join(missing)) + rows = [ + {name: (row.get(name) or "").strip() for name in REQUIRED_HEADERS} + for row in reader + ] + if not rows: + raise ValueError("CSV must contain at least one data row") + if any(not row["project"] for row in rows): + raise ValueError("Every CSV row must have a project name") + return rows + + +def inspect_source(workspace: Path, source_csv: str) -> dict[str, Any]: + """Return a bounded summary without exposing files outside the workspace.""" + source = _workspace_path(workspace, source_csv, ".csv") + rows = _load_rows(source) + statuses = Counter(row["status"] or "Unspecified" for row in rows) + owners = sorted({row["owner"] for row in rows if row["owner"]}) + return { + "source": source.relative_to(workspace.expanduser().resolve()).as_posix(), + "row_count": len(rows), + "status_counts": dict(sorted(statuses.items())), + "owners": owners, + "required_columns": list(REQUIRED_HEADERS), + } + + +def _verify_docx(path: Path) -> dict[str, Any]: + if not path.is_file() or path.stat().st_size == 0: + raise RuntimeError("DOCX output was not created or is empty") + try: + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + required = {"[Content_Types].xml", "word/document.xml"} + if not required.issubset(names): + raise RuntimeError("Output is a ZIP file but not a valid DOCX package") + bad_member = archive.testzip() + if bad_member: + raise RuntimeError(f"DOCX contains a corrupt member: {bad_member}") + except zipfile.BadZipFile as exc: + raise RuntimeError("Output is not a valid DOCX ZIP package") from exc + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return {"size_bytes": path.stat().st_size, "sha256": digest} + + +def build_report( + workspace: Path, + source_csv: str, + output_docx: str, + week_ending: str, + title: str = "Weekly project report", + overwrite: bool = False, +) -> dict[str, Any]: + """Create a formatted DOCX atomically and return verification metadata.""" + try: + parsed_date = date.fromisoformat(week_ending) + except ValueError as exc: + raise ValueError("week_ending must use YYYY-MM-DD") from exc + + source = _workspace_path(workspace, source_csv, ".csv") + output = _workspace_path(workspace, output_docx, ".docx") + rows = _load_rows(source) + if output.exists() and not overwrite: + raise FileExistsError( + f"Output already exists: {output.name}; set overwrite=true to replace it" + ) + output.parent.mkdir(parents=True, exist_ok=True) + + try: + from docx import Document + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.shared import Pt + except ImportError as exc: + raise RuntimeError( + "Install dependencies with: pip install -r requirements.txt" + ) from exc + + document = Document() + heading = document.add_heading(title.strip() or "Weekly project report", level=0) + heading.alignment = WD_ALIGN_PARAGRAPH.CENTER + subtitle = document.add_paragraph(f"Week ending: {parsed_date.isoformat()}") + subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER + + statuses = Counter(row["status"] or "Unspecified" for row in rows) + document.add_heading("Executive summary", level=1) + document.add_paragraph( + f"{len(rows)} projects reported. " + + "; ".join(f"{name}: {count}" for name, count in sorted(statuses.items())) + + "." + ) + + document.add_heading("Project details", level=1) + table = document.add_table(rows=1, cols=5) + table.style = "Table Grid" + labels = ("Project", "Status", "Progress", "Owner", "Next step") + for cell, label in zip(table.rows[0].cells, labels, strict=True): + cell.text = label + for run in cell.paragraphs[0].runs: + run.bold = True + for row in rows: + cells = table.add_row().cells + values = ( + row["project"], + row["status"], + row["progress"], + row["owner"], + row["next_step"], + ) + for cell, value in zip(cells, values, strict=True): + cell.text = value + + document.add_heading("Risks and follow-up", level=1) + risks = [ + row for row in rows if row["status"].strip().lower() in {"blocked", "at risk"} + ] + if risks: + for row in risks: + document.add_paragraph( + f"{row['project']}: {row['status']} — {row['next_step']}", + style="List Bullet", + ) + else: + document.add_paragraph("No blocked or at-risk projects were reported.") + + normal_style = document.styles["Normal"] + normal_style.font.name = "Arial" + normal_style.font.size = Pt(10.5) + + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + prefix=f".{output.stem}-", suffix=".docx", dir=output.parent, delete=False + ) as handle: + temporary_path = Path(handle.name) + document.save(temporary_path) + verification = _verify_docx(temporary_path) + if output.exists() and not overwrite: + raise FileExistsError( + f"Output appeared during generation: {output.name}; retry with a new name" + ) + os.replace(temporary_path, output) + temporary_path = None + finally: + if temporary_path and temporary_path.exists(): + temporary_path.unlink() + + return { + "output": output.relative_to(workspace.expanduser().resolve()).as_posix(), + "week_ending": parsed_date.isoformat(), + "row_count": len(rows), + **verification, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--source", default="data.csv") + parser.add_argument("--output", required=True) + parser.add_argument("--week-ending", required=True) + parser.add_argument("--title", default="Weekly project report") + parser.add_argument("--overwrite", action="store_true") + args = parser.parse_args() + result = build_report( + args.workspace, + args.source, + args.output, + args.week_ending, + args.title, + args.overwrite, + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/headless-weekly-report/requirements.txt b/examples/headless-weekly-report/requirements.txt new file mode 100644 index 0000000..59b58c2 --- /dev/null +++ b/examples/headless-weekly-report/requirements.txt @@ -0,0 +1,2 @@ +mcp>=2.0,<3 +python-docx>=1.2,<2 diff --git a/examples/headless-weekly-report/test_report_builder.py b/examples/headless-weekly-report/test_report_builder.py new file mode 100644 index 0000000..07dfa9d --- /dev/null +++ b/examples/headless-weekly-report/test_report_builder.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import csv +import tempfile +import unittest +import zipfile +from pathlib import Path + +from report_builder import build_report, inspect_source + + +class ReportBuilderTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.workspace = Path(self.temporary.name) + with (self.workspace / "data.csv").open( + "w", encoding="utf-8", newline="" + ) as handle: + writer = csv.DictWriter( + handle, + fieldnames=("project", "status", "progress", "next_step", "owner"), + ) + writer.writeheader() + writer.writerow( + { + "project": "Onboarding", + "status": "On track", + "progress": "Pilot ready", + "next_step": "Invite users", + "owner": "Lin", + } + ) + writer.writerow( + { + "project": "Export", + "status": "Blocked", + "progress": "Schema ready", + "next_step": "Request access", + "owner": "Wang", + } + ) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_inspect_source_returns_bounded_summary(self) -> None: + result = inspect_source(self.workspace, "data.csv") + self.assertEqual(result["row_count"], 2) + self.assertEqual(result["status_counts"], {"Blocked": 1, "On track": 1}) + self.assertEqual(result["owners"], ["Lin", "Wang"]) + + def test_paths_cannot_escape_workspace(self) -> None: + with self.assertRaisesRegex(ValueError, "escapes REPORT_WORKSPACE"): + inspect_source(self.workspace, "../private.csv") + + def test_missing_required_column_is_rejected(self) -> None: + (self.workspace / "bad.csv").write_text( + "project,status\nA,On track\n", encoding="utf-8" + ) + with self.assertRaisesRegex(ValueError, "missing required columns"): + inspect_source(self.workspace, "bad.csv") + + def test_build_report_creates_verified_docx(self) -> None: + result = build_report( + self.workspace, + "data.csv", + "weekly-report.docx", + "2026-09-04", + "Team weekly report", + ) + output = self.workspace / result["output"] + self.assertEqual(result["row_count"], 2) + self.assertEqual(len(result["sha256"]), 64) + with zipfile.ZipFile(output) as archive: + document_xml = archive.read("word/document.xml").decode("utf-8") + self.assertIn("Team weekly report", document_xml) + self.assertIn("Request access", document_xml) + + def test_existing_output_requires_explicit_overwrite(self) -> None: + output = self.workspace / "weekly-report.docx" + output.write_bytes(b"existing") + with self.assertRaisesRegex(FileExistsError, "overwrite=true"): + build_report( + self.workspace, + "data.csv", + output.name, + "2026-09-04", + ) + self.assertEqual(output.read_bytes(), b"existing") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/headless-weekly-report/workspace/data.csv b/examples/headless-weekly-report/workspace/data.csv new file mode 100644 index 0000000..a7ab03a --- /dev/null +++ b/examples/headless-weekly-report/workspace/data.csv @@ -0,0 +1,4 @@ +project,status,progress,next_step,owner +Agent onboarding,On track,Completed the Loomy toolbox guide,Run a five-user pilot,Lin +Knowledge cleanup,At risk,Indexed 82 percent of legacy files,Resolve duplicate document owners,Chen +Weekly automation,Blocked,Validated the report schema,Request access to the finance export,Wang