diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6da950e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: Offline regression tests +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python: ['3.9', '3.12'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install -r requirements-dev.txt + - name: Install CJK font for Linux + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fonts-noto-cjk fonts-wqy-zenhei + - run: python -m unittest discover -s tests -v + - name: Validate CFF CJK export on Linux + if: runner.os == 'Linux' && matrix.python == '3.12' + env: + ATLAS_FONT: /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc + run: python -m unittest discover -s tests -p test_runtime.py -v diff --git a/AGENTS.md b/AGENTS.md index 5da578b..0671721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Full guide: [docs/install.md](docs/install.md) ```bash export ATLAS_ROOT="${ATLAS_ROOT:-$HOME/AtlasAgent}" -bash "$ATLAS_ROOT/scripts/atlas-agent doctor" +bash "$ATLAS_ROOT/scripts/atlas-agent" doctor ``` ## Skills (9) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79d448c..1b6d4eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,10 +18,21 @@ ## 验证 +离线回归检查(安装测试仅写入临时目录,不修改真实的用户配置): + +```bash +python3 -m pip install -r requirements-dev.txt +python3 -m unittest discover -s tests -v +for script in scripts/install.sh scripts/atlas-agent scripts/atlas-env.sh; do + bash -n "$script" || exit 1 +done +``` + +可选的联网连通检查: + ```bash export ATLAS_ROOT="$(pwd)" python3 scripts/weather_client.py forecast --city Paris --days 3 -bash scripts/install.sh cursor # 本地测试安装 ``` ## License diff --git a/README.md b/README.md index f899742..d12cbd9 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,15 @@ scripts/ → 可执行 CLI(doctor 会检查) | `atlas-agent install --agent=cursor` | 仅安装到 Cursor | | `atlas-agent install --safe` | 只输出手动步骤 | | `atlas-agent install --dry-run` | 预览将执行的操作 | +| `atlas-agent install --overwrite` | 备份后替换已有不同内容的技能 | | `atlas-agent doctor` | 健康检查 | | `atlas-agent doctor --json` | JSON 格式输出 | +| `atlas-agent doctor --offline` | 跳过联网检查,明确标记降级状态 | +| `atlas-agent install --env=none` | 保留 shell 配置 | | `atlas-agent check-update` | 更新指引 | +安装默认保留已有技能:相同内容跳过,遇到不同内容先停止。检查冲突后,可用 `--overwrite` 备份并替换;备份路径见安装输出。详见[安装说明](docs/install.md#existing-skills)。 + --- ## 贡献 diff --git a/docs/install.md b/docs/install.md index 25e584c..e190220 100644 --- a/docs/install.md +++ b/docs/install.md @@ -91,6 +91,18 @@ This installs: 2. `ATLAS_ROOT` in `~/.zshrc` or `~/.bashrc` (unless `--safe`) 3. Optional: `pip3 install fpdf2` for PDF export +### Existing skills + +Shell configuration is maintained in a project-owned block; moving the checkout updates that block while preserving other settings. `--env=none` skips shell changes. Identical skills are left in place. If any existing skill differs, installation stops before changing any target's skills or shell configuration. Review the conflict first. To replace changed skills explicitly: + +```bash +bash "$ATLAS_ROOT/scripts/atlas-agent" install --agent=cursor --overwrite +``` + +Each replaced skill is moved to a unique directory under the target's `skills/.atlas-backups/`; the installer prints the full backup path. The new copy is staged before the old skill moves, and a failed final move restores the previous skill. Backups are retained for manual recovery. This protects each replaced skill; a later installation failure can still leave earlier skills updated. + +For an isolated installation or test, `ATLAS_USER_HOME` selects a separate user configuration directory for `install.sh`; it defaults to the current user's home. This setting does not change `HOME`; doctor checks the same selected user configuration directory. + **L0 zero-config channels (no API key):** - Weather → Open-Meteo (`weather_client.py`) @@ -181,3 +193,7 @@ rm -rf ~/.cursor/skills/atlas-agent ~/.cursor/skills/weather ~/.cursor/skills/cu # Remove ATLAS_ROOT line from ~/.zshrc if desired # rm -rf ~/AtlasAgent ``` + +## Diagnostic states + +`doctor` validates all runtime scripts and all nine skills in each supported agent directory. Required failures return exit code 1; missing optional PDF support or uninstalled agent copies produce a degraded status. `doctor --json` uses valid JSON serialization for every path. `doctor --offline` skips API requests and reports degraded readiness rather than claiming online availability. PDF capability is verified by a small CJK export, including the installed version and font. diff --git a/docs/update.md b/docs/update.md index 221b511..2fda3ba 100644 --- a/docs/update.md +++ b/docs/update.md @@ -25,19 +25,27 @@ export ATLAS_ROOT="${ATLAS_ROOT:-$HOME/AtlasAgent}" git -C "$ATLAS_ROOT" pull # 2. Re-install skills (detect user's agent or use all) -bash "$ATLAS_ROOT/scripts/atlas-agent install --env=auto --agent=all +bash "$ATLAS_ROOT/scripts/atlas-agent" install --env=auto --agent=all # 3. Verify -bash "$ATLAS_ROOT/scripts/atlas-agent doctor" +bash "$ATLAS_ROOT/scripts/atlas-agent" doctor ``` If `git pull` fails (local changes), ask the user before resetting. +If installation reports a skill conflict, inspect the installed changes and the new repository version. The existing skills remain intact. After choosing to replace them, run: + +```bash +bash "$ATLAS_ROOT/scripts/atlas-agent" install --env=auto --agent=all --overwrite +``` + +This preserves each replaced skill under its target's `skills/.atlas-backups/` and prints the backup path. Identical skills are skipped. A later failure may leave earlier skills updated; retain the backups until the update is verified. + ### Optional ```bash pip3 install -U fpdf2 -bash "$ATLAS_ROOT/scripts/atlas-agent check-update" +bash "$ATLAS_ROOT/scripts/atlas-agent" check-update ``` Report doctor output to the user. Mention new destinations or skills if CHANGELOG/README changed. diff --git a/examples/chengdu-5days-zh.md b/examples/chengdu-5days-zh.md new file mode 100644 index 0000000..b376dee --- /dev/null +++ b/examples/chengdu-5days-zh.md @@ -0,0 +1,127 @@ +# 双人成都5日游 · AtlasAgent 实际验证输出 + +2026年10月19–23日|2人|成都当地集合|当地总预算 CNY 5000|可吃微辣 + +这是一份经用户确认条件的测试方案:没有已订交通和酒店,不包含出发城市往返成都的大交通。景点、酒店和餐厅的存在与部分参观规则有来源;酒店、餐饮、都江堰票价与交通费用为规划估算,并非实时库存报价。餐厅菜品是点餐建议,是否供应及微辣做法需门店确认。 + +## 行程主题与预算结果 + +熊猫、城市文化和水利遗产,地铁/步行加少量打车,4晚固定住市中心,避免频繁搬酒店。 +推荐方案A当地合计 CNY 4279,人均 CNY 2139.50;含10%应急金,低于两人预算 CNY 721。仅D4为较紧日,其余保留休息与雨天调整空间。 +国内行程,签证分析不适用;酒店入住和实名预约携带本人有效证件。时区 Asia/Shanghai(UTC+8)。 + +## 五日行程 + +| 日期 | 上午、下午与晚间安排 | 住宿 | +|---|---|---| +| D1 2026-10-19(周一) | 12:00 成都当地集合、午餐;14:00 酒店入住;15:30 春熙路/太古里慢逛(约2小时);18:00 晚餐后休息。下雨改逛商场,抵达日保留半天。 | 鼓楼智选假日酒店候选,未预订 | +| D2 2026-10-20(周二) | 07:00 出发;08:00-11:30 熊猫基地(已核实需实名预约,尚未购票);12:30 市区午餐;14:00-16:00 文殊院/文殊坊,17:00 返回。雨天优先室内科普展区,不保证熊猫出场。 | 同上 | +| D3 2026-10-21(周三) | 09:00-11:30 成都博物馆(周三,预约待办);12:30 青华路午餐;14:00-16:00 杜甫草堂;晚间自由休息。博物馆门票/预约只通过官方渠道,草堂实际开放时间待再次确认。 | 同上 | +| D4 2026-10-22(周四) | 唯一较紧日:07:30 前往城际站;预留约2小时单程门到门;10:00-14:30 都江堰水利工程,午餐自带便当;15:00-17:30 返蓉,18:30 晚餐。不叠加青城山;车次、余票和景区开放情况未实时核实。若持续降雨或通行不佳,改为市区方案B。 | 同上 | +| D5 2026-10-23(周五) | 08:15 先退房并寄存行李;09:00-11:00 武侯祠;11:00-12:00 锦里慢逛;12:30 市区午餐,下午自由活动;16:00 回酒店取行李;17:00 提前晚餐,18:00 成都当地行程结束。若另订返程交通,按实际出发时刻至少留足到站/机场缓冲。 | 无,退房 | + +## 天气脚本实跑结果 + +Open-Meteo 查询:2026-10-12 10:05 北京时间。返回日期与10月19–23日逐日完全匹配;对应城市网格预报,不是景区微气候实测。查询距出行较远,后段天气仅看趋势,出行前1–2天重新查询;不要把市区预报直接当作都江堰的精确预报。 + +| 日期 | 天气 | 最低–最高 | 日降水估计 | 最大降水概率 | +|---|---|---|---|---| +| 2026-10-19 | 毛毛雨 | 18.7–21.1°C | 6.0 mm | 14% | +| 2026-10-20 | 小毛毛雨 | 17.9–21.9°C | 0.9 mm | 25% | +| 2026-10-21 | 小毛毛雨 | 15.9–19.0°C | 2.1 mm | 24% | +| 2026-10-22 | 小毛毛雨 | 14.1–20.5°C | 2.1 mm | 20% | +| 2026-10-23 | 小毛毛雨 | 15.6–21.9°C | 1.2 mm | 23% | + +随身雨伞、防滑鞋、薄外套。D1遇雨优先逛商场;D3以室内博物馆为主,草堂遇强降雨可取消并调整票费;D4是否去都江堰,以临近日期的当地预报和景区公告决定。不要因预算已计入而强行出游。 + +## 每日用餐建议 + +以下是人均规划金额,不是菜单报价;同一餐厅重复使用以减少未知店名和路线波动,可在预算内换成已核实门店。标签只针对已知“微辣”偏好,不是过敏安全保证。酒店餐厅是否供应晚餐、具体菜名、退房后是否可用餐尚未确认;不方便时用同区域经核实餐厅替代。 + +| 餐次 | 餐厅/方式 | 建议菜品与限制标签 | 人均估算 | +|---|---|---|---| +| D1 早餐 | 集合前自理 | 按出发地安排,不计入成都当地预算 | 房价内/不计 | +| D1 午餐 | 龙抄手(春熙路总店) | 清汤抄手、少辣小吃拼盘 [需调整并确认] | 约 CNY 45 | +| D1 晚餐 | 鼓楼智选假日酒店餐厅 | 少辣简餐建议;菜名与晚餐供应待酒店确认 [需调整并确认] | 约 CNY 75 | +| D2 早餐 | 酒店早餐厅 | 粥、蛋、水果 [符合已知微辣限制];以所订双早房型为准 | 房价内/不计 | +| D2 午餐 | 龙抄手(春熙路总店) | 清汤抄手/蒸点,红油另放 [需调整并确认] | 约 CNY 45 | +| D2 晚餐 | 酒店餐厅 | 清淡热菜、米饭;当日菜单需确认 [需调整并确认] | 约 CNY 75 | +| D3 早餐 | 酒店早餐厅 | 面点、粥、水果 [符合已知微辣限制] | 房价内/不计 | +| D3 午餐 | 陈麻婆豆腐(青华路) | 麻婆豆腐请求微辣、荷叶粉蒸肉、米饭;可否减辣由门店确认 [需调整并确认] | 约 CNY 50 | +| D3 晚餐 | 龙抄手(春熙路总店) | 小吃组合/汤圆,辣油另放 [需调整并确认] | 约 CNY 70 | +| D4 早餐 | 酒店早餐厅 | 早餐提前准备,打包是否提供需酒店确认 [需调整并确认] | 房价内/不计 | +| D4 午餐 | 自备便当(前一晚采购) | 熟食/面包、水果、水 [注意保存及食用时间] | 约 CNY 50 | +| D4 晚餐 | 酒店餐厅 | 清汤面/熟食简餐作为点餐建议,按实际菜单替换 [需调整并确认] | 约 CNY 60 | +| D5 早餐 | 酒店早餐厅 | 粥、面点、水果 [符合已知微辣限制] | 房价内/不计 | +| D5 午餐 | 龙抄手(春熙路总店) | 清汤抄手、蒸点 [需调整并确认] | 约 CNY 45 | +| D5 晚餐 | 酒店餐厅(退房后用餐需确认) | 提前晚餐建议,按实际菜单选少辣简餐 [需调整并确认] | 约 CNY 55 | + +15个餐次中的房价内早餐不重复计费;D1集合前早餐不计入当地预算。上表另加两人人均共30元的饮水零食,共形成两人 CNY 1200 餐饮预算。火锅若替换一餐,可选鸳鸯/菌汤锅,额外费用从预算余额中支付。麻婆豆腐和红油小吃默认有辣,明确请求减辣,不能把“清淡”视为自动满足。 + +## 两人预算 + +当地与用户货币均为CNY,无需换汇。修复后的汇率脚本对 CNY→CNY 返回等额和无汇率日期,没有伪造汇率。单独的 CNY→USD 调用只是工具连通性测试,不用于此国内预算。 + +| 项目 | 两人费用 | +|---|---| +| 住宿4晚(双人房,含双早预算) | CNY 1400 | +| 餐饮5天(含饮水零食) | CNY 1200 | +| 门票预算 | CNY 470 | +| 市内及都江堰交通预留 | CNY 620 | +| 观光车、雨具等杂费预留 | CNY 200 | +| 基础费用小计 | CNY 3890 | +| 应急10%(按小计计算) | CNY 389 | +| 合计 | CNY 4279 | +| 人均 | CNY 2139.50 | +| 距总预算余额 | CNY 721 | + +门票预算构成:熊猫基地55×2=110;武侯祠50×2=100;杜甫草堂预留50×2=100;都江堰预留80×2=160;成都博物馆常设展按免费预约计算,不含另收费展览。熊猫基地与武侯祠基础票价有官方页面支持,草堂和都江堰金额为待核实的预算预留。观光车另从杂费中支付。 +交通620为市内/到站接驳约400和都江堰往返城际及接驳约220的预留;无实际车次报价。不含往返成都机票/铁路票,也不包括大额购物和保险。超过预留时重新核算总额。 + +## 住宿候选 + +| 候选 | 位置与特点 | 预算与核实状态 | +|---|---|---| +| 成都鼓楼智选假日酒店 | 大墙西街72号,市中心;本方案以此作住宿候选 | 按CNY350/间夜估算,4晚1400;官方页面说明每间房双早,仍须核对实际房型与价格,未预订 | +| 成都天府广场智选假日酒店 | 金盾路52号;天府广场与人民公园一带 | 备选,按同档预算筛选;官方页面有含早餐说明,实际房价和库存待查 | + +## 交通选择与雨天方案 + +| 比较 | 地铁+步行+少量打车 | 全程租车 | 建议 | +|---|---|---|---| +| 市区游览 | 少找停车位,适合市中心路线 | 停车与拥堵费用未报价 | 地铁为主 | +| 都江堰一天 | 城际车加当地接驳,车次待定 | 灵活,但需核实租金与停车 | 先比较实际车次,雨天可直接取消 | +| 五天成本 | 已预留CNY620/两人 | 未报价,不能宣称更省钱 | 按本方案先不租车 | + +方案B:保留D1、D2、D3和D5,把D4改为市区休息、人民公园短游或室内商场;不新增另一座未经核实开馆日的博物馆。删除都江堰票费160,把交通预算降为450。基础费用3560,应急356,合计CNY3916,人均CNY1958。比A省363,适合持续降雨或不想早起的人。 + +| 方案 | 总费用(两人,估算) | 体力 | 内容 | 适用情况 | +|---|---|---|---|---| +| A 城市+都江堰 | CNY4279 | 一天较紧 | 熊猫、城市文化、水利工程 | 天气和景区通行条件合适 | +| B 市区慢游 | CNY3916 | 更轻松 | 熊猫、文化、休息 | 下雨或想降低成本 | + +先保留A作完整对比;出行前天气不适合都江堰时选B。所有预约与购票尚待本人办理,测试没有自动购票或付款。 + +## 出发前清单 + +- 身份证等有效证件;核对住宿入住人和实名预约资料,避免在公开行程文件写证件号码。 +- 熊猫基地实名预约,成都博物馆使用官网/官方公众号;周一不安排成都博物馆。 +- 确认酒店价格、双早、取消政策和晚餐供应;餐厅少辣做法现场确认。 +- 核实杜甫草堂、都江堰开放时间与票价,以及城际车次;实际返程出发点已订后再调整D5。 +- 下载地图、准备支付方式和雨具;需要保险时另行核价,不把未购买保险写成已包含。 + +## 来源与数据边界 + +信息核查日期:2026-10-12。以下页面支持景点规则、实体信息和酒店设施;估算价格不引用成实时成交价。 + +1. [熊猫基地官网](https://www.panda.org.cn/cn/):实名预约、开放时段;[官方日文版票价](https://m.panda.org.cn/jp/):成人55元。 +2. [成都博物馆参观指南](https://www.cdmuseum.com/zhinan/):周一闭馆(法定节假日除外)、入馆时段。来源冲突时以场馆最新公告为准。 +3. [武侯祠官方票务说明](https://whpwm.cdwuhouci.cn/index):基础全价票50元。 +4. [UNESCO都江堰介绍](https://whc.unesco.org/en/list/1001):遗产内容;此页不支持门票或交通报价。 +5. [鼓楼智选假日酒店官方页面](https://www.ihg.com/holidayinnexpress/hotels/tw/zh/chengdu/ctugl/hoteldetail)、[天府广场酒店官方页面](https://www.ihg.com/holidayinnexpress/hotels/us/en/chengdu/ctuts/hoteldetail):地址、早餐与设施;不是所选日期的房价。 +6. [龙抄手高德地点页面](https://www.amap.com/place/B001C7UMYH):店名与地点;[陈麻婆豆腐米其林页面](https://guide.michelin.com/sg/zh_CN/chengdu-municipality/chengdu/restaurant/chen-mapo-tofu-qinghua-road):地址与招牌菜。未核实实时营业和当日菜单。 +7. [Open-Meteo官方接口说明](https://open-meteo.com/en/docs?past_days=1):预报窗口、日期及当前天气字段;原始响应另存运行记录。 + +## 这次运行如何完成 + +AtlasAgent是技能文档与工具组合,没有一个自动生成整份旅行攻略的模型程序。本次实际执行了修复后的天气、当前天气、日历、汇率和doctor脚本,再按更新后的技能流程编排这份行程;PDF由仓库的md2pdf实际导出。脚本原始响应、预算/约束校验、PDF内容核对与回归测试日志与本文件一并保存。人工编排内容没有冒充脚本直接生成的文本。 diff --git a/examples/chengdu-5days.json b/examples/chengdu-5days.json new file mode 100644 index 0000000..228078f --- /dev/null +++ b/examples/chengdu-5days.json @@ -0,0 +1,211 @@ +{ + "title": "双人成都5日游", + "start": "2026-10-19", + "end": "2026-10-23", + "party_size": 2, + "budget": 5000, + "currency": "CNY", + "origin": "成都当地集合;不含往返成都的大交通", + "diet": "可吃微辣,未提供其他限制", + "activities": [ + { + "date": "2026-10-19", + "day": "D1", + "schedule": "12:00 成都当地集合、午餐;14:00 酒店入住;15:30 春熙路/太古里慢逛(约2小时);18:00 晚餐后休息。下雨改逛商场,抵达日保留半天。", + "venues": [ + "春熙路", + "太古里" + ], + "stay": "鼓楼智选假日酒店候选,未预订", + "intense": false + }, + { + "date": "2026-10-20", + "day": "D2", + "schedule": "07:00 出发;08:00-11:30 熊猫基地(已核实需实名预约,尚未购票);12:30 市区午餐;14:00-16:00 文殊院/文殊坊,17:00 返回。雨天优先室内科普展区,不保证熊猫出场。", + "venues": [ + "成都大熊猫繁育研究基地", + "文殊院" + ], + "stay": "同上", + "intense": false + }, + { + "date": "2026-10-21", + "day": "D3", + "schedule": "09:00-11:30 成都博物馆(周三,预约待办);12:30 青华路午餐;14:00-16:00 杜甫草堂;晚间自由休息。博物馆门票/预约只通过官方渠道,草堂实际开放时间待再次确认。", + "venues": [ + "成都博物馆", + "杜甫草堂" + ], + "stay": "同上", + "intense": false + }, + { + "date": "2026-10-22", + "day": "D4", + "schedule": "唯一较紧日:07:30 前往城际站;预留约2小时单程门到门;10:00-14:30 都江堰水利工程,午餐自带便当;15:00-17:30 返蓉,18:30 晚餐。不叠加青城山;车次、余票和景区开放情况未实时核实。若持续降雨或通行不佳,改为市区方案B。", + "venues": [ + "都江堰水利工程" + ], + "stay": "同上", + "intense": true + }, + { + "date": "2026-10-23", + "day": "D5", + "schedule": "08:15 先退房并寄存行李;09:00-11:00 武侯祠;11:00-12:00 锦里慢逛;12:30 市区午餐,下午自由活动;16:00 回酒店取行李;17:00 提前晚餐,18:00 成都当地行程结束。若另订返程交通,按实际出发时刻至少留足到站/机场缓冲。", + "venues": [ + "武侯祠", + "锦里" + ], + "stay": "无,退房", + "intense": false + } + ], + "meals": [ + { + "day": 1, + "meal": "早餐", + "venue": "集合前自理", + "dishes": "按出发地安排,不计入成都当地预算", + "per_person_estimate": 0 + }, + { + "day": 1, + "meal": "午餐", + "venue": "龙抄手(春熙路总店)", + "dishes": "清汤抄手、少辣小吃拼盘 [需调整并确认]", + "per_person_estimate": 45 + }, + { + "day": 1, + "meal": "晚餐", + "venue": "鼓楼智选假日酒店餐厅", + "dishes": "少辣简餐建议;菜名与晚餐供应待酒店确认 [需调整并确认]", + "per_person_estimate": 75 + }, + { + "day": 2, + "meal": "早餐", + "venue": "酒店早餐厅", + "dishes": "粥、蛋、水果 [符合已知微辣限制];以所订双早房型为准", + "per_person_estimate": 0 + }, + { + "day": 2, + "meal": "午餐", + "venue": "龙抄手(春熙路总店)", + "dishes": "清汤抄手/蒸点,红油另放 [需调整并确认]", + "per_person_estimate": 45 + }, + { + "day": 2, + "meal": "晚餐", + "venue": "酒店餐厅", + "dishes": "清淡热菜、米饭;当日菜单需确认 [需调整并确认]", + "per_person_estimate": 75 + }, + { + "day": 3, + "meal": "早餐", + "venue": "酒店早餐厅", + "dishes": "面点、粥、水果 [符合已知微辣限制]", + "per_person_estimate": 0 + }, + { + "day": 3, + "meal": "午餐", + "venue": "陈麻婆豆腐(青华路)", + "dishes": "麻婆豆腐请求微辣、荷叶粉蒸肉、米饭;可否减辣由门店确认 [需调整并确认]", + "per_person_estimate": 50 + }, + { + "day": 3, + "meal": "晚餐", + "venue": "龙抄手(春熙路总店)", + "dishes": "小吃组合/汤圆,辣油另放 [需调整并确认]", + "per_person_estimate": 70 + }, + { + "day": 4, + "meal": "早餐", + "venue": "酒店早餐厅", + "dishes": "早餐提前准备,打包是否提供需酒店确认 [需调整并确认]", + "per_person_estimate": 0 + }, + { + "day": 4, + "meal": "午餐", + "venue": "自备便当(前一晚采购)", + "dishes": "熟食/面包、水果、水 [注意保存及食用时间]", + "per_person_estimate": 50 + }, + { + "day": 4, + "meal": "晚餐", + "venue": "酒店餐厅", + "dishes": "清汤面/熟食简餐作为点餐建议,按实际菜单替换 [需调整并确认]", + "per_person_estimate": 60 + }, + { + "day": 5, + "meal": "早餐", + "venue": "酒店早餐厅", + "dishes": "粥、面点、水果 [符合已知微辣限制]", + "per_person_estimate": 0 + }, + { + "day": 5, + "meal": "午餐", + "venue": "龙抄手(春熙路总店)", + "dishes": "清汤抄手、蒸点 [需调整并确认]", + "per_person_estimate": 45 + }, + { + "day": 5, + "meal": "晚餐", + "venue": "酒店餐厅(退房后用餐需确认)", + "dishes": "提前晚餐建议,按实际菜单选少辣简餐 [需调整并确认]", + "per_person_estimate": 55 + } + ], + "costs": [ + { + "name": "住宿4晚(双人房,含双早预算)", + "amount": 1400 + }, + { + "name": "餐饮5天(含饮水零食)", + "amount": 1200 + }, + { + "name": "门票预算", + "amount": 470 + }, + { + "name": "市内及都江堰交通预留", + "amount": 620 + }, + { + "name": "观光车、雨具等杂费预留", + "amount": 200 + } + ], + "subtotal": 3890, + "contingency_rate": 0.1, + "contingency": 389.0, + "total": 4279.0, + "per_person": 2139.5, + "headroom": 721.0, + "prices_are_estimates": true, + "weather_dates": [ + "2026-10-19", + "2026-10-20", + "2026-10-21", + "2026-10-22", + "2026-10-23" + ], + "museum_closed_weekday": 0, + "source_checked_at": "2026-10-12" +} \ No newline at end of file diff --git a/examples/tokyo-7days-zh.md b/examples/tokyo-7days-zh.md index 79ca3e2..27c0bf5 100644 --- a/examples/tokyo-7days-zh.md +++ b/examples/tokyo-7days-zh.md @@ -1,14 +1,14 @@ # 东京 7 日深度游 · AtlasAgent 示例输出 -> 方案 A | 上海出发 | 2026-10-01 至 10-07 | 2 人 | 预算人均 ¥8000 +> 方案 A(超预算对比方案)| 上海出发 | 2026-10-01 至 10-07 | 2 人 | 目标预算人均 CNY 8000 ## 概览 | 项目 | 内容 | |------|------| | ✈️ 交通 | 上海浦东 → 东京成田 往返,约 ¥2800/人 | -| 🌤️ 天气 | 10 月 15–22°C,偶雨,备轻便外套 | -| 🛂 签证 | 中国护照 30 天免签(出行前核实最新政策) | +| 🌤️ 天气 | 10 月气候参考 15–22°C,偶雨,备轻便外套;不是对应日期的实时预报 | +| 🛂 签证 | 中国大陆普通护照须提前办理赴日旅游签证;出行前核实本人适用要求 | | 🎯 定位 | 美食 + 文化 + 拍照,地铁出行不租车 | | 🕐 时区 | UTC+9 | @@ -20,8 +20,8 @@ | D2 10/02(周五)**紧** | 08:00 丰洲市场 → 11:00 teamLab → 15:00 浅草寺+仲见世 → 19:00 晴空塔 | 新宿 | | D3 10/03(周六) | 09:00 明治神宫 → 13:00 原宿/表参道 → 16:00 涩谷十字路口 → 18:00 代代木公园 | 新宿 | | D4 10/04(周日) | 08:00 富士山一日游(巴士)→ 19:00 返回 | 新宿 | -| D5 10/05(周一) | 10:00 上野公园+国立博物馆 → 14:00 秋叶原 → 18:00 银座散步 | 新宿 | -| D6 10/06(周二) | 10:00 筑地外市场 → 14:00 台场 → 18:00 东京塔 | 新宿 | +| D5 10/05(周一) | 10:00 筑地外市场 → 14:00 台场 → 18:00 东京塔 | 新宿 | +| D6 10/06(周二) | 10:00 上野公园+东京国立博物馆 → 14:00 秋叶原 → 18:00 银座散步 | 新宿 | | D7 10/07(周三) | 10:00 最后购物 → 13:00 前往成田 → 返程 | - | ## 🍜 必吃清单 @@ -36,7 +36,7 @@ ## 🏨 推荐住宿 -| 酒店 | 区域 | 价格/晚 | +| 酒店 | 区域 | 价格/晚(CNY,估算) | |------|------|---------| | 新宿华盛顿酒店 | 新宿 | ¥900–1200 | | 浅草豪景酒店 | 浅草 | ¥800–1100 | @@ -50,18 +50,25 @@ | 餐饮 | ¥4200 | | 门票/活动 | ¥3000 | | 交通 | ¥900 | -| 应急 10% | ¥2000 | -| **合计** | **¥22300** | -| **人均** | **¥11150** | +| 基础费用小计 | ¥20300 | +| 应急 10%(按基础费用计算) | ¥2030 | +| **合计** | **¥22330** | +| **人均** | **¥11165** | -> 汇率日期:2026-06-25 | 略超预算,可降住宿档位或去掉富士山一日游 +> 本表直接以 CNY 估算,未进行实时汇率换算。人均比目标多 CNY 3165(约 39.6%),此方案不满足 CNY 8000 的预算约束。降低住宿档位或删除活动后,须重新核算并生成预算内方案;不能仅凭删去一个活动宣称达标。 ## ⚠️ 注意事项 - teamLab 需提前 2–4 周预约 - 10/04 周日富士山团较满,建议提前订巴士 - 日本无小费 -- 备现金 ¥20000 +- 备适量日元现金,例如 JPY 20000;人民币预算与日元现金分开标注 +- 东京国立博物馆通常周一闭馆,示例安排在周二;其他临时闭馆及具体场馆营业时间须另行核实 + +## 核验来源 + +- 签证:[日本外务省中国公民签证说明](https://www.mofa.go.jp/j_info/visit/visa/topics/china.html),核验日期 2026-10-10 +- 博物馆:[东京国立博物馆开馆与闭馆说明](https://www.tnm.jp/modules/r_free_page/index.php?id=113),核验日期 2026-10-10 --- diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..08e55c0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements-pdf.txt +PyMuPDF>=1.24.3,<2 diff --git a/requirements-pdf.txt b/requirements-pdf.txt new file mode 100644 index 0000000..da45e01 --- /dev/null +++ b/requirements-pdf.txt @@ -0,0 +1 @@ +fpdf2>=2.8.4,<3 diff --git a/scripts/atlas-agent b/scripts/atlas-agent index bddd05c..66293b7 100755 --- a/scripts/atlas-agent +++ b/scripts/atlas-agent @@ -17,8 +17,8 @@ usage() { AtlasAgent — global travel planning skill pack Usage: - atlas-agent install [--env=auto] [--agent=all|cursor|claude|codex|copilot|hermes] [--safe] [--dry-run] - atlas-agent doctor [--json] + atlas-agent install [--env=auto|none] [--agent=all|cursor|claude|codex|copilot|hermes] [--overwrite] [--safe] [--dry-run] + atlas-agent doctor [--json] [--offline] atlas-agent check-update Environment: @@ -27,58 +27,17 @@ EOF } cmd_doctor() { - local json=false - [[ "${1:-}" == "--json" ]] && json=true - - local py_ok=false scripts_ok=false weather_ok=false currency_ok=false fpdf_ok=false - local cursor_ok=false claude_ok=false - - command -v python3 >/dev/null 2>&1 && py_ok=true - [[ -d "$ATLAS_ROOT/scripts" && -f "$ATLAS_ROOT/scripts/weather_client.py" ]] && scripts_ok=true - - if $py_ok && $scripts_ok; then - python3 "$ATLAS_ROOT/scripts/weather_client.py" forecast --city Tokyo --days 1 >/dev/null 2>&1 && weather_ok=true - python3 "$ATLAS_ROOT/scripts/currency_client.py" convert 1 --from USD --to JPY >/dev/null 2>&1 && currency_ok=true - fi - - python3 -c "import fpdf" 2>/dev/null && fpdf_ok=true - [[ -f "${HOME}/.cursor/skills/atlas-agent/SKILL.md" ]] && cursor_ok=true - [[ -f "${HOME}/.claude/skills/atlas-agent/SKILL.md" ]] && claude_ok=true - - if $json; then - cat </dev/null 2>&1; then + local wants_json=false + for arg in "$@"; do [[ "$arg" == "--json" ]] && wants_json=true; done + if $wants_json; then + echo '{"status":"unavailable","python3":false,"error":"Python 3 not found"}' + else + fail "Python 3 not found" + fi + return 1 fi + python3 "$REPO_ROOT/scripts/doctor.py" "$@" } cmd_check_update() { @@ -92,24 +51,36 @@ cmd_check_update() { log "Run: git -C \"$ATLAS_ROOT\" pull" log "Then: bash \"$ATLAS_ROOT/scripts/install.sh\" all" fi + log "Changed skills are preserved by default. After review, add --overwrite to replace them with backups." } cmd_install() { - local agent="all" safe=false dry=false + local agent="all" safe=false dry=false overwrite=false env_mode="auto" for arg in "$@"; do case "$arg" in - --env=auto|--env=*) ;; + --env=auto|--env=none) env_mode="${arg#*=}" ;; --agent=*) agent="${arg#*=}" ;; --safe) safe=true ;; --dry-run) dry=true ;; + --overwrite) overwrite=true ;; --help|-h) usage; exit 0 ;; + *) echo "Unknown install option: $arg" >&2; exit 1 ;; esac done + case "$agent" in + all|cursor|claude|codex|copilot|agents|hermes) ;; + *) echo "Unknown agent: $agent" >&2; exit 1 ;; + esac if $dry; then log "[dry-run] Would clone/use ATLAS_ROOT=$ATLAS_ROOT" log "[dry-run] Would install skills to agent=$agent" - $safe || log "[dry-run] Would append ATLAS_ROOT to ~/.zshrc" + if $overwrite; then + log "[dry-run] Would back up and replace changed skills" + else + log "[dry-run] Would stop if an existing skill differs" + fi + if ! $safe && [[ "$env_mode" == "auto" ]]; then log "[dry-run] Would update the managed ATLAS_ROOT block"; fi $safe || log "[dry-run] Would run: pip3 install fpdf2 (optional)" exit 0 fi @@ -118,16 +89,25 @@ cmd_install() { log "Safe mode — manual steps:" log " 1. git clone https://github.com/wpfbcr/AtlasAgent.git ~/AtlasAgent" log " 2. export ATLAS_ROOT=~/AtlasAgent" - log " 3. bash \$ATLAS_ROOT/scripts/install.sh $agent" + local safe_args="" + if [[ "$env_mode" == "none" ]]; then safe_args=" --no-env"; fi + if $overwrite; then + log " 3. bash \"\$ATLAS_ROOT/scripts/install.sh\" $agent --overwrite$safe_args" + else + log " 3. bash \"\$ATLAS_ROOT/scripts/install.sh\" $agent$safe_args" + fi log " 4. pip3 install fpdf2 # optional" - log " 5. atlas-agent doctor" + log " 5. bash \"\$ATLAS_ROOT/scripts/atlas-agent\" doctor" exit 0 fi - bash "$REPO_ROOT/scripts/install.sh" "$agent" + local install_args=("$agent") + if [[ "$env_mode" == "none" ]]; then install_args+=(--no-env); fi + if $overwrite; then install_args+=(--overwrite); fi + bash "$REPO_ROOT/scripts/install.sh" "${install_args[@]}" - if ! python3 -c "import fpdf" 2>/dev/null; then - pip3 install fpdf2 -q 2>/dev/null && ok "Installed fpdf2" || warn "Could not auto-install fpdf2 — run: pip3 install fpdf2" + if ! python3 -c "import importlib.metadata; importlib.metadata.version('fpdf2')" 2>/dev/null; then + python3 -m pip install -r "$REPO_ROOT/requirements-pdf.txt" -q 2>/dev/null && ok "Installed fpdf2" || warn "Could not auto-install fpdf2 — run: pip3 install fpdf2" fi log "" @@ -136,7 +116,7 @@ cmd_install() { case "${1:-}" in install) shift; cmd_install "$@" ;; - doctor) shift; cmd_doctor "${1:-}" ;; + doctor) shift; cmd_doctor "$@" ;; check-update) shift; cmd_check_update ;; ""|-h|--help|help) usage ;; *) echo "Unknown command: $1"; usage; exit 1 ;; diff --git a/scripts/currency_client.py b/scripts/currency_client.py index 4c720b5..fe2a62b 100755 --- a/scripts/currency_client.py +++ b/scripts/currency_client.py @@ -1,70 +1,105 @@ #!/usr/bin/env python3 -"""Frankfurter (ECB) currency client for AtlasAgent. No API key required. - -Usage: - python3 currency_client.py rates --base USD - python3 currency_client.py convert 1000 --from USD --to JPY - python3 currency_client.py convert 500 --from EUR --to CNY --to GBP -""" - +"""Frankfurter currency CLI. Repeated --to values are combined.""" import argparse +from datetime import date import json +import math +import re import sys import urllib.parse -import urllib.request -from typing import List, Optional +from http_client import APIError, fetch_json BASE_URL = "https://api.frankfurter.app" -def fetch_json(url: str) -> dict: - req = urllib.request.Request(url, headers={"User-Agent": "AtlasAgent/1.0"}) - with urllib.request.urlopen(req, timeout=15) as resp: - return json.loads(resp.read().decode()) - - -def cmd_rates(base: str, targets: Optional[List[str]]) -> None: - params = {"from": base.upper()} +def currency_code(text): + text = text.upper() + if not re.fullmatch(r"[A-Z]{3}", text): + raise argparse.ArgumentTypeError("Currency must be a three-letter ISO code") + return text + + +def conversion(amount, src, targets, round_results=True): + if not math.isfinite(amount) or amount < 0: + raise ValueError("Amount must be finite and non-negative") + targets = list(dict.fromkeys(targets)) + foreign = [t for t in targets if t != src] + # Get unit rates so zero amounts and same-currency conversions work locally. + data = fetch_json(BASE_URL + "/latest?" + urllib.parse.urlencode({"from": src, "to": ",".join(foreign)})) if foreign else {"date": None, "base": src, "rates": {}} + rates = data.get("rates", {}) + if not isinstance(rates, dict): + raise APIError("Malformed exchange-rate response") + if foreign and (not data.get("date") or data.get("base") != src): + raise APIError("Missing or mismatched exchange-rate metadata") + if data.get("date"): + try: + date.fromisoformat(data["date"]) + except (ValueError, TypeError) as exc: + raise APIError("Invalid exchange-rate date") from exc + result = {} + for target in targets: + rate = 1 if target == src else rates.get(target) + if not isinstance(rate, (int, float)) or not math.isfinite(rate) or rate <= 0: + raise APIError("Missing or invalid exchange rate for " + target) + value = amount * rate + if not math.isfinite(value): + raise ValueError("Converted amount is too large") + result[target] = round(value, 2) if round_results else value + return {"date": data.get("date"), "base": src, "amount": amount, "rates": result, "source": "Frankfurter/ECB" if foreign else "identity (no FX needed)"} + + +def cmd_rates(base, targets, as_json=False): if targets: - params["to"] = ",".join(t.upper() for t in targets) - url = f"{BASE_URL}/latest?" + urllib.parse.urlencode(params) - data = fetch_json(url) - print(f"📅 汇率日期: {data['date']}") - print(f"💱 基准: {data['base']}") - for cur, rate in sorted(data.get("rates", {}).items()): - print(f" 1 {data['base']} = {rate:.4f} {cur}") - - -def cmd_convert(amount: float, src: str, targets: List[str]) -> None: - src = src.upper() - tgt = ",".join(t.upper() for t in targets) - url = f"{BASE_URL}/latest?amount={amount}&from={src}&to={tgt}" - data = fetch_json(url) - print(f"📅 汇率日期: {data['date']}") - print(f"💰 {amount:,.2f} {src} =") - for cur, val in sorted(data.get("rates", {}).items()): - print(f" {val:,.2f} {cur}") + data = conversion(1, base.upper(), [t.upper() for t in targets], round_results=False) + else: + data = fetch_json(BASE_URL + "/latest?" + urllib.parse.urlencode({"from": base.upper()})) + if not data.get("date") or data.get("base") != base.upper() or not isinstance(data.get("rates"), dict) or not data["rates"]: + raise APIError("Missing exchange-rate data") + if as_json: + print(json.dumps(data, ensure_ascii=False, indent=2)) + else: + print("汇率日期: {} | 基准: {}".format(data.get("date") or "无需换汇", data.get("base", base))) + for cur, rate in sorted(data["rates"].items()): + print("1 {} = {:.4f} {}".format(base, rate, cur)) + + +def cmd_convert(amount, src, targets, as_json=False): + data = conversion(amount, src.upper(), [t.upper() for t in targets]) + if as_json: + print(json.dumps(data, ensure_ascii=False, indent=2)) + else: + print("汇率日期: {}".format(data["date"] or "无需换汇")) + print("{:,.2f} {} =".format(amount, data["base"])) + for cur, value in sorted(data["rates"].items()): + print(" {:,.2f} {}".format(value, cur)) def main(): - p = argparse.ArgumentParser(description="AtlasAgent currency client (Frankfurter/ECB)") + p = argparse.ArgumentParser(description=__doc__) sub = p.add_subparsers(dest="cmd", required=True) - - r = sub.add_parser("rates", help="Show exchange rates") - r.add_argument("--base", default="USD") - r.add_argument("--to", nargs="*", help="Target currencies") - - c = sub.add_parser("convert", help="Convert amount") + r = sub.add_parser("rates") + r.add_argument("--base", type=currency_code, default="USD") + r.add_argument("--to", type=currency_code, nargs="+", action="extend") + c = sub.add_parser("convert") c.add_argument("amount", type=float) - c.add_argument("--from", dest="src", required=True) - c.add_argument("--to", nargs="+", required=True) - + c.add_argument("--from", dest="src", type=currency_code, required=True) + c.add_argument("--to", type=currency_code, nargs="+", action="extend", required=True) + for child in (r, c): + child.add_argument("--json", action="store_true") args = p.parse_args() - if args.cmd == "rates": - cmd_rates(args.base, args.to) - elif args.cmd == "convert": - cmd_convert(args.amount, args.src, args.to) + try: + if args.cmd == "rates": + cmd_rates(args.base, args.to, args.json) + else: + cmd_convert(args.amount, args.src, args.to, args.json) + except (APIError, ValueError, KeyError) as exc: + if args.json: + print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False)) + else: + print("Currency error: " + str(exc), file=sys.stderr) + return 2 + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/scripts/doctor.py b/scripts/doctor.py new file mode 100644 index 0000000..bd311d4 --- /dev/null +++ b/scripts/doctor.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Structured health checks. Required failures return 1, optional gaps degrade.""" +import argparse +from concurrent.futures import ThreadPoolExecutor +import importlib.metadata +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +REQUIRED_SCRIPTS = ['weather_client.py', 'currency_client.py', 'holiday_check.py', 'http_client.py', 'doctor.py', 'environment.py', 'md2pdf.py', 'atlas-env.sh', 'install.sh', 'atlas-agent'] +AGENTS = {'cursor': '.cursor/skills', 'claude': '.claude/skills', 'codex': '.codex/skills', 'copilot': '.agents/skills', 'hermes': '.hermes/skills/productivity'} + + +def run_probe(command, timeout=35): + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout) + return {'ok': result.returncode == 0, 'detail': (result.stderr or result.stdout).strip()[-1200:] if result.returncode else ''} + except (OSError, subprocess.TimeoutExpired) as exc: + return {'ok': False, 'detail': str(exc)} + + +def pdf_probe(root): + try: + version = importlib.metadata.version('fpdf2') + if tuple(int(n) for n in version.split('.')[:3]) < (2, 8, 4): + return {'ok': False, 'detail': 'fpdf2 >= 2.8.4 required; found ' + version} + try: + importlib.metadata.distribution('fpdf') + legacy = True + except importlib.metadata.PackageNotFoundError: + legacy = False + if legacy: + return {'ok': False, 'detail': 'Legacy PyFPDF conflicts with fpdf2; use a clean environment'} + with tempfile.TemporaryDirectory(prefix='atlas-pdf-probe-') as folder: + source = Path(folder) / 'probe.md' + source.write_text('导出验证\n连续正文完整显示\n', encoding='utf-8') + return run_probe([sys.executable, str(root / 'scripts/md2pdf.py'), str(source)], timeout=20) + except importlib.metadata.PackageNotFoundError: + return {'ok': False, 'detail': 'Optional fpdf2 is not installed'} + + +def diagnose(root, user_home, offline=False): + root = Path(root) + checks = {'python3': {'ok': sys.version_info >= (3, 9), 'detail': sys.version.split()[0]}, 'scripts': {'ok': all((root / 'scripts' / file).is_file() for file in REQUIRED_SCRIPTS), 'detail': ''}} + skills = sorted(path.parent.name for path in (root / 'skills').glob('*/SKILL.md')) + checks['skill_pack'] = {'ok': len(skills) == 9, 'detail': '{} skills found'.format(len(skills))} + with ThreadPoolExecutor(max_workers=3) as pool: + jobs = {'fpdf2': pool.submit(pdf_probe, root)} + if checks['scripts']['ok'] and not offline: + jobs['weather_api'] = pool.submit(run_probe, [sys.executable, str(root / 'scripts/weather_client.py'), 'forecast', '--lat', '30.67', '--lon', '104.07', '--timezone', 'Asia/Shanghai', '--days', '1', '--json']) + jobs['currency_api'] = pool.submit(run_probe, [sys.executable, str(root / 'scripts/currency_client.py'), 'convert', '1', '--from', 'USD', '--to', 'JPY', '--json']) + for name, job in jobs.items(): + checks[name] = job.result() + if offline: + checks.update({name: {'ok': None, 'detail': 'Skipped (--offline)'} for name in ('weather_api', 'currency_api')}) + else: + for name in ('weather_api', 'currency_api'): + checks.setdefault(name, {'ok': False, 'detail': 'Runtime scripts missing'}) + installs = {} + for agent, location in AGENTS.items(): + missing = [name for name in skills if not (Path(user_home) / location / name / 'SKILL.md').is_file()] + installs[agent] = {'ok': bool(skills) and not missing, 'missing': missing} + required = ['python3', 'scripts', 'skill_pack'] + ([] if offline else ['weather_api', 'currency_api']) + ready = all(checks[name]['ok'] for name in required) + status = 'unavailable' if not ready else 'ready' if checks['fpdf2']['ok'] and not offline and any(a['ok'] for a in installs.values()) else 'degraded' + return {'atlas_root': str(root), 'status': status, 'checks': checks, 'installed_skills': installs, 'skill_cursor': installs['cursor']['ok'], 'skill_claude': installs['claude']['ok'], **{name: item['ok'] for name, item in checks.items()}}, 0 if ready else 1 + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--json', action='store_true') + p.add_argument('--offline', action='store_true') + args = p.parse_args() + root = os.environ.get('ATLAS_ROOT', str(Path(__file__).resolve().parents[1])) + report, code = diagnose(root, os.environ.get('ATLAS_USER_HOME', str(Path.home())), args.offline) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print('AtlasAgent Doctor | ' + report['status']) + print('ATLAS_ROOT: ' + report['atlas_root']) + for name, item in report['checks'].items(): + print(' {} {} {}'.format('OK' if item['ok'] else 'SKIP' if item['ok'] is None else 'FAIL', name, item['detail'])) + for agent, item in report['installed_skills'].items(): + print(' {} skills: {}'.format(agent, 'all installed' if item['ok'] else ', '.join(item['missing']) or 'source pack missing')) + return code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/environment.py b/scripts/environment.py new file mode 100644 index 0000000..5584192 --- /dev/null +++ b/scripts/environment.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Maintain only AtlasAgent's shell configuration block, with safe quoting.""" +import os +from pathlib import Path +import re +import shlex +import sys +import tempfile + +BEGIN = "# >>> AtlasAgent >>>" +END = "# <<< AtlasAgent <<<" + + +def update_environment(path, root): + path = Path(path) + original = path.read_text() if path.exists() else "" + if original.count(BEGIN) != original.count(END): + raise ValueError("Incomplete AtlasAgent configuration block; existing file preserved") + block = BEGIN + "\nexport ATLAS_ROOT=" + shlex.quote(str(root)) + "\n" + END + pattern = re.compile(re.escape(BEGIN) + r"\n.*?" + re.escape(END), re.S) + if pattern.search(original): + updated = pattern.sub(lambda match: block, original) + else: + # Migrate the legacy project-owned two-line snippet; leave manual settings intact. + legacy = re.compile(r'^# AtlasAgent \(https://github\.com/wpfbcr/AtlasAgent\)\nexport ATLAS_ROOT=.*$', re.M) + if legacy.search(original): + updated = legacy.sub(lambda match: block, original) + else: + updated = original.rstrip("\n") + "\n\n" + block + "\n" + if updated == original: + return + # Replace the referent so an existing symlinked shell rc keeps its link. + target = path.resolve() if path.is_symlink() else path + fd, temp = tempfile.mkstemp(prefix=".atlas-env-", dir=str(target.parent)) + try: + with os.fdopen(fd, "w") as output: + output.write(updated) + os.chmod(temp, target.stat().st_mode & 0o777 if target.exists() else 0o600) + os.replace(temp, target) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +if __name__ == "__main__": + try: + update_environment(sys.argv[1], sys.argv[2]) + except (OSError, UnicodeError, ValueError) as exc: + print("Cannot update shell configuration: " + str(exc), file=sys.stderr) + sys.exit(1) diff --git a/scripts/holiday_check.py b/scripts/holiday_check.py index 8f0039f..c474306 100755 --- a/scripts/holiday_check.py +++ b/scripts/holiday_check.py @@ -12,6 +12,7 @@ import json from datetime import date, timedelta from typing import Dict, List, Optional, Any +import sys WEEKDAY_ZH = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] @@ -22,6 +23,8 @@ "rome": "梵蒂冈博物馆周日闭馆(除特殊开放日)", "tokyo": "多数博物馆周一闭馆", "new york": "部分博物馆周一或周二闭馆", + "chengdu": "成都博物馆周一闭馆(法定节假日除外);具体场馆仍须查官网", + "成都": "成都博物馆周一闭馆(法定节假日除外);具体场馆仍须查官网", "beijing": "故宫周一闭馆(法定节假日除外)", } @@ -39,6 +42,10 @@ def analyze(start: date, days: int, city: Optional[str]) -> Dict[str, Any]: + if not 1 <= days <= 366: + raise ValueError("--days must be between 1 and 366") + if start > date.max - timedelta(days=days - 1): + raise ValueError("Date range exceeds supported calendar") rows: List[dict] = [] city_key = (city or "").lower().strip() hint = None @@ -79,8 +86,11 @@ def main(): p.add_argument("--json", action="store_true") args = p.parse_args() - start = date.fromisoformat(args.start) - result = analyze(start, args.days, args.city) + try: + start = date.fromisoformat(args.start) + result = analyze(start, args.days, args.city) + except (ValueError, OverflowError) as exc: + p.error(str(exc)) if args.json: print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/scripts/http_client.py b/scripts/http_client.py new file mode 100644 index 0000000..d3242e2 --- /dev/null +++ b/scripts/http_client.py @@ -0,0 +1,32 @@ +"""Bounded JSON requests shared by AtlasAgent's public API clients.""" +import json +import time +import urllib.error +import urllib.request + + +class APIError(RuntimeError): + pass + + +def fetch_json(url, timeout=5, retries=2): + request = urllib.request.Request(url, headers={"User-Agent": "AtlasAgent/1.2"}) + for attempt in range(retries + 1): + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8")) + if not isinstance(data, dict): + raise APIError("Service returned a non-object JSON response") + if data.get("error"): + raise APIError(str(data.get("reason", "Service rejected the request"))) + return data + except urllib.error.HTTPError as exc: + if exc.code not in (429, 500, 502, 503, 504) or attempt == retries: + raise APIError("Service HTTP error {}".format(exc.code)) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + if attempt == retries: + raise APIError("Service unavailable after {} attempts: {}".format(attempt + 1, exc)) from exc + except (ValueError, UnicodeError) as exc: + raise APIError("Service returned invalid JSON") from exc + time.sleep(0.2 * (attempt + 1)) + raise APIError("Service unavailable") diff --git a/scripts/install.sh b/scripts/install.sh index 03ec230..aa9d8ae 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,30 +6,85 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ATLAS_ROOT="${ATLAS_ROOT:-$REPO_ROOT}" TARGET="${1:-all}" +ATLAS_USER_HOME="${ATLAS_USER_HOME:-$HOME}" +OVERWRITE=false +WRITE_ENV=true +STAGING_DIR="" + +if [[ $# -gt 0 ]]; then shift; fi +for arg in "$@"; do + case "$arg" in + --overwrite) OVERWRITE=true ;; + --no-env) WRITE_ENV=false ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +cleanup() { + if [[ -n "$STAGING_DIR" ]]; then rm -rf -- "$STAGING_DIR"; fi +} +trap cleanup EXIT + +same_skill() { + [[ -d "$2" && ! -L "$2" ]] && diff -qr "$1" "$2" >/dev/null 2>&1 +} + +preflight() { + local dest="$1" skill name target + for skill in "$REPO_ROOT"/skills/*/; do + name="$(basename "$skill")" + [[ "$name" == "_shared" ]] && continue + target="$dest/$name" + if [[ -e "$target" || -L "$target" ]]; then + if ! same_skill "$skill" "$target" && ! $OVERWRITE; then + echo "Conflict: $target differs from AtlasAgent. Existing files were preserved." >&2 + echo "Review the existing skill, then use --overwrite to replace it with a backup." >&2 + return 1 + fi + fi + done +} install_to() { - local dest="$1" + local dest="$1" skill name target backup mkdir -p "$dest" for skill in "$REPO_ROOT"/skills/*/; do name="$(basename "$skill")" [[ "$name" == "_shared" ]] && continue - rm -rf "$dest/$name" - cp -R "$skill" "$dest/$name" - echo " ✓ $name → $dest/$name" + target="$dest/$name" + if same_skill "$skill" "$target"; then + echo " · $name already up to date" + continue + fi + # Copy completely before moving any existing installation. + STAGING_DIR="$(mktemp -d "$dest/.atlas-stage.XXXXXX")" + cp -R "${skill%/}" "$STAGING_DIR/$name" + backup="" + if [[ -e "$target" || -L "$target" ]]; then + # Check again in case a destination appeared after preflight. + if ! $OVERWRITE; then + echo "Conflict: $target appeared during installation; preserved." >&2 + return 1 + fi + mkdir -p "$dest/.atlas-backups" + backup="$(mktemp -d "$dest/.atlas-backups/$name.XXXXXX")/$name" + mv -- "$target" "$backup" + fi + if ! mv -- "$STAGING_DIR/$name" "$target"; then + if [[ -n "$backup" ]]; then mv -- "$backup" "$target"; fi + echo "Failed to install $name; previous skill restored if present." >&2 + return 1 + fi + rmdir "$STAGING_DIR" + STAGING_DIR="" + echo " ✓ $name → $target" + if [[ -n "$backup" ]]; then echo " · Backup: $backup"; fi done } write_env_snippet() { - local rc_file="$1" - local line="export ATLAS_ROOT=\"$ATLAS_ROOT\"" - if [[ -f "$rc_file" ]] && grep -q 'ATLAS_ROOT=' "$rc_file" 2>/dev/null; then - echo " · ATLAS_ROOT already in $(basename "$rc_file")" - else - echo "" >> "$rc_file" - echo "# AtlasAgent (https://github.com/wpfbcr/AtlasAgent)" >> "$rc_file" - echo "$line" >> "$rc_file" - echo " ✓ wrote ATLAS_ROOT to $(basename "$rc_file")" - fi + python3 "$REPO_ROOT/scripts/environment.py" "$1" "$ATLAS_ROOT" + echo " ✓ configured ATLAS_ROOT in $(basename "$1")" } echo "AtlasAgent installer" @@ -39,31 +94,31 @@ echo "" case "$TARGET" in all) - install_to "${HOME}/.cursor/skills" - install_to "${HOME}/.claude/skills" - install_to "${HOME}/.codex/skills" - install_to "${HOME}/.agents/skills" - if [[ -d "${HOME}/.hermes/skills/productivity" ]]; then - install_to "${HOME}/.hermes/skills/productivity" + destinations=("$ATLAS_USER_HOME/.cursor/skills" "$ATLAS_USER_HOME/.claude/skills" "$ATLAS_USER_HOME/.codex/skills" "$ATLAS_USER_HOME/.agents/skills") + if [[ -d "$ATLAS_USER_HOME/.hermes/skills/productivity" ]]; then + destinations+=("$ATLAS_USER_HOME/.hermes/skills/productivity") fi ;; - cursor) install_to "${HOME}/.cursor/skills" ;; - claude) install_to "${HOME}/.claude/skills" ;; - codex) install_to "${HOME}/.codex/skills" ;; - copilot|agents) install_to "${HOME}/.agents/skills" ;; - hermes) - install_to "${HOME}/.hermes/skills/productivity" - ;; + cursor) destinations=("$ATLAS_USER_HOME/.cursor/skills") ;; + claude) destinations=("$ATLAS_USER_HOME/.claude/skills") ;; + codex) destinations=("$ATLAS_USER_HOME/.codex/skills") ;; + copilot|agents) destinations=("$ATLAS_USER_HOME/.agents/skills") ;; + hermes) destinations=("$ATLAS_USER_HOME/.hermes/skills/productivity") ;; *) - echo "Usage: $0 [all|cursor|claude|codex|copilot|hermes]" + echo "Usage: $0 [all|cursor|claude|codex|copilot|hermes] [--overwrite]" exit 1 ;; esac -if [[ -f "${HOME}/.zshrc" ]]; then - write_env_snippet "${HOME}/.zshrc" -elif [[ -f "${HOME}/.bashrc" ]]; then - write_env_snippet "${HOME}/.bashrc" +for dest in "${destinations[@]}"; do preflight "$dest"; done +for dest in "${destinations[@]}"; do install_to "$dest"; done + +if ! $WRITE_ENV; then + echo " · Shell configuration skipped" +elif [[ -f "$ATLAS_USER_HOME/.zshrc" ]]; then + write_env_snippet "$ATLAS_USER_HOME/.zshrc" +elif [[ -f "$ATLAS_USER_HOME/.bashrc" ]]; then + write_env_snippet "$ATLAS_USER_HOME/.bashrc" fi echo "" diff --git a/scripts/md2pdf.py b/scripts/md2pdf.py index 8564d71..2b746c1 100755 --- a/scripts/md2pdf.py +++ b/scripts/md2pdf.py @@ -1,234 +1,201 @@ #!/usr/bin/env python3 -"""Convert Markdown travel itineraries to PDF with CJK support. - -Usage: - python3 md2pdf.py examples/tokyo-7days-zh.md - python3 md2pdf.py examples/ # convert all .md in directory - -Requires: pip3 install fpdf2 -""" - -from fpdf import FPDF -import re +"""Render Markdown itineraries with wrapped tables, CJK and visible diet labels.""" +import argparse +import importlib.metadata import os +from pathlib import Path +import re import sys -FONT_SIZE = 9.5 -LINE_HEIGHT = 5.5 - -FONT_CANDIDATES = [ - "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", - "/System/Library/Fonts/PingFang.ttc", - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", - "C:\\Windows\\Fonts\\msyh.ttc", -] - - -def find_font() -> str: - for path in FONT_CANDIDATES: - if os.path.isfile(path): - return path - raise SystemExit("No CJK font found. Install Noto Sans CJK or use macOS/Windows system fonts.") +FONT_CANDIDATES = ['/System/Library/Fonts/Supplemental/Arial Unicode.ttf', '/System/Library/Fonts/PingFang.ttc', '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc', 'C:\\Windows\\Fonts\\msyh.ttc'] +SYMBOLS = {'🟢': '[符合已知限制]', '🟡': '[需调整并确认]', '🔴': '[含受限成分]', '⚠': '[注意]', '⭐': '★', '✈': '[交通]', '🌤': '[天气]', '🛂': '[签证]', '🎯': '[定位]', '🕐': '[时区]', '🍜': '[美食]', '🏨': '[住宿]', '💰': '[预算]', '📍': '[地点]', '📅': '[日期]', '💱': '[汇率]'} + +try: + if tuple(int(n) for n in importlib.metadata.version('fpdf2').split('.')[:3]) < (2, 8, 4): + raise ImportError('fpdf2 >= 2.8.4 required') + from fpdf import FPDF, FontFace + from fpdf.errors import FPDFException + from fontTools.ttLib import TTFont +except (ImportError, importlib.metadata.PackageNotFoundError) as exc: + raise SystemExit('PDF export requires fpdf2 >= 2.8.4: python3 -m pip install -r requirements-pdf.txt') from exc + + +def find_font(): + configured = os.environ.get('ATLAS_FONT') + candidates = [configured] if configured else FONT_CANDIDATES + for candidate in candidates: + if candidate and Path(candidate).is_file(): + with TTFont(candidate, fontNumber=0) as font: + cff = 'CFF ' in font or 'CFF2' in font + version = tuple(int(n) for n in importlib.metadata.version('fpdf2').split('.')[:3]) + if cff and version < (2, 8, 9): + if configured: + raise ValueError('CFF fonts require fpdf2 >= 2.8.9 (Python 3.10+); use a TrueType CJK font on Python 3.9') + continue + return candidate + raise ValueError('No CJK font found. Set ATLAS_FONT to a CJK TTF/TTC font or install Noto Sans CJK.') class MD2PDF(FPDF): - def __init__(self, title="", font_path=""): - super().__init__("P", "mm", "A4") - self.add_font("C", "", font_path) - self.add_font("C", "B", font_path) + def __init__(self, title='', font_path=''): + super().__init__('P', 'mm', 'A4') + self.set_margins(15, 15, 15) self.set_auto_page_break(True, 20) - self.doc_title = title + self.add_font('C', '', font_path) + self.add_font('C', 'B', font_path) + with TTFont(font_path, fontNumber=0) as font: + self.characters = set(font.getBestCmap()) + self.doc_title = self._clean(title) + self.set_title(self.doc_title) + + def _clean(self, text): + for symbol, label in SYMBOLS.items(): + text = text.replace(symbol, label) + text = text.replace('\ufe0f', '').replace('\ufe0e', '').replace('\u200d', '') + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'(?]+>', '', text) + text = text.replace('\\|', '|') + return ''.join(ch if ch in '\n\t' or ord(ch) in self.characters else '[U+{:X}]'.format(ord(ch)) for ch in text) def header(self): if self.page_no() > 1: - self.set_font("C", "", 8) - self.set_text_color(150, 150, 150) - self.cell(0, 5, self.doc_title, align="C") - self.ln(8) + self.set_font('C', '', 8) + self.set_text_color(130, 130, 130) + self.cell(0, 5, self.doc_title, align='C', new_x='LMARGIN', new_y='NEXT') + self.ln(3) def footer(self): self.set_y(-15) - self.set_font("C", "", 8) - self.set_text_color(150, 150, 150) - self.cell(0, 10, f"- {self.page_no()} -", align="C") + self.set_font('C', '', 8) + self.set_text_color(130, 130, 130) + self.cell(0, 8, '- {} -'.format(self.page_no()), align='C') + + def paragraph(self, text, size=9.5, indent=0): + self.set_font('C', '', size) + self.set_text_color(40, 40, 40) + self.set_x(self.l_margin + indent) + self.multi_cell(self.epw - indent, 5.5, self._clean(text), new_x='LMARGIN', new_y='NEXT', wrapmode='CHAR') def render(self, text): - lines = text.split("\n") + lines = text.splitlines() i = 0 while i < len(lines): line = lines[i] - - if re.match(r"^---+$", line.strip()): - y = self.get_y() - self.set_draw_color(200, 200, 200) - self.line(15, y, 195, y) - self.ln(6) - i += 1 - continue - - if line.strip().startswith("```"): - code_lines = [] + if line.strip().startswith('```'): + code = [] i += 1 - while i < len(lines) and not lines[i].strip().startswith("```"): - code_lines.append(lines[i]) + while i < len(lines) and not lines[i].strip().startswith('```'): + code.append(lines[i]) i += 1 - i += 1 - self._code(code_lines) - continue - - if "|" in line and line.strip().startswith("|"): - table_lines = [] - while i < len(lines) and "|" in lines[i] and lines[i].strip().startswith("|"): - table_lines.append(lines[i]) + self._code(code) + elif line.strip().startswith('|'): + table = [] + while i < len(lines) and lines[i].strip().startswith('|'): + table.append(lines[i]) i += 1 - self._table(table_lines) - continue - - h = re.match(r"^(#{1,6})\s+(.+)$", line) - if h: - level = len(h.group(1)) - title = h.group(2).strip() - sizes = {1: 20, 2: 15, 3: 12, 4: 10.5, 5: 10, 6: 10} - self.set_font("C", "B", sizes.get(level, 10)) - self.set_text_color(30, 30, 30) - if level <= 2: - self.ln(3) - self.multi_cell(0, 6.5, title) - self.ln(2) - if level == 1: - self.set_draw_color(60, 60, 60) - self.line(15, self.get_y(), 195, self.get_y()) - self.ln(4) - i += 1 - continue - - if line.strip().startswith(">"): - self.set_font("C", "", 9) - self.set_text_color(100, 100, 100) - self.set_x(20) - self.multi_cell(170, 5, line.strip().lstrip("> ")) - self.ln(1) - i += 1 - continue - - li = re.match(r"^(\s*)[-*]\s+(.+)$", line) - num_li = re.match(r"^(\s*)\d+[.)]\s+(.+)$", line) - if li or num_li: - content = li.group(2) if li else num_li.group(2) - self.set_font("C", "", 9) - self.set_text_color(50, 50, 50) - self.set_x(20) - bullet = chr(8226) if li else chr(8212) - self.cell(5, 5, bullet) - self.multi_cell(165, 5, self._clean(content)) - i += 1 + self._table(table) continue - - if not line.strip(): + elif re.fullmatch(r'\s*-{3,}\s*', line): self.ln(3) - i += 1 - continue - - self.set_font("C", "", FONT_SIZE) - self.set_text_color(40, 40, 40) - self.multi_cell(0, LINE_HEIGHT, self._clean(line)) + self.set_draw_color(190, 190, 190) + self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y()) + self.ln(3) + elif re.match(r'^#{1,6}\s', line): + match = re.match(r'^(#{1,6})\s+(.+)', line) + level = len(match[1]) + self.set_font('C', 'B', {1: 18, 2: 13, 3: 11}.get(level, 10)) + self.set_text_color(25, 50, 65) + # Reserve room for the heading and some following content. + if self.get_y() + (40 if level <= 2 else 24) > self.page_break_trigger: + self.add_page() + self.ln(3) + self.multi_cell(0, 7, self._clean(match[2]), new_x='LMARGIN', new_y='NEXT', wrapmode='CHAR') + self.ln(2) + elif line.strip().startswith('>'): + self.paragraph(line.strip().lstrip('> '), size=9, indent=4) + self.ln(2) + elif re.match(r'^\s*[-*]\s+', line): + self.paragraph('- ' + re.sub(r'^\s*[-*]\s+', '', line), indent=4) + elif re.match(r'^\s*\d+[.)]\s+', line): + self.paragraph(line.strip(), indent=4) + elif not line.strip(): + self.ln(2) + else: + self.paragraph(line) i += 1 def _code(self, lines): - self.ln(2) self.set_fill_color(245, 245, 245) - self.set_font("C", "", 8) - self.set_text_color(80, 80, 80) - for cl in lines: - self.set_x(17) - self.cell(176, 4.5, cl[:120], fill=True) - self.ln() - self.ln(3) + for line in lines: + self.paragraph(line, size=8, indent=2) + self.ln(2) def _table(self, lines): - self.ln(2) rows = [] - for l in lines: - cells = [c.strip() for c in l.strip().strip("|").split("|")] - if not re.match(r"^[\s\-:]+$", "".join(cells)): - rows.append(cells) + for line in lines: + body = line.strip()[1:] + if body.endswith('|'): + body = body[:-1] + cells = [cell.strip() for cell in re.split(r'(?]+>", "", text) - return re.sub(r"\s+", " ", text).strip() - - -def convert_file(path: str, font_path: str) -> str: - with open(path, "r", encoding="utf-8") as fh: - content = fh.read() - title = os.path.splitext(os.path.basename(path))[0] - pdf = MD2PDF(title=title, font_path=font_path) + +def convert_file(path, font_path): + source = Path(path) + content = source.read_text(encoding='utf-8') + heading = re.search(r'^#\s+(.+)', content, re.M) + pdf = MD2PDF(title=heading[1] if heading else source.stem, font_path=font_path) pdf.add_page() pdf.render(content) - out = path.replace(".md", ".pdf") - pdf.output(out) - return out + output = source.with_suffix('.pdf') + pdf.output(str(output)) + return str(output) def main(): - if len(sys.argv) < 2: - print("Usage: python3 md2pdf.py ") - sys.exit(1) - - target = sys.argv[1] - font_path = find_font() - - if os.path.isfile(target): - if not target.endswith(".md"): - raise SystemExit("Input must be a .md file") - out = convert_file(target, font_path) - size = os.path.getsize(out) / 1024 - print(f"OK {target} -> {out} ({size:.0f} KB)") - return - - if not os.path.isdir(target): - raise SystemExit(f"Not found: {target}") - - files = sorted(f for f in os.listdir(target) if f.endswith(".md")) - if not files: - print(f"No .md files in {target}") - return - - for f in files: - path = os.path.join(target, f) - out = convert_file(path, font_path) - size = os.path.getsize(out) / 1024 - print(f"OK {f} -> {os.path.basename(out)} ({size:.0f} KB)") - print(f"Done. {len(files)} file(s) converted.") - - -if __name__ == "__main__": - main() + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('target', type=Path) + args = p.parse_args() + try: + if args.target.is_dir(): + sources = sorted(args.target.glob('*.md')) + elif args.target.is_file() and args.target.suffix == '.md': + sources = [args.target] + else: + raise ValueError('Input must be an existing .md file or directory') + if not sources: + raise ValueError('No .md files found') + font_path = find_font() + for source in sources: + output = convert_file(source, font_path) + print('OK {} -> {} ({:.0f} KB)'.format(source, output, Path(output).stat().st_size / 1024)) + except (OSError, ValueError, RuntimeError, FPDFException) as exc: + print('PDF error: ' + str(exc), file=sys.stderr) + return 2 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/weather_client.py b/scripts/weather_client.py index ae9580a..3a278da 100755 --- a/scripts/weather_client.py +++ b/scripts/weather_client.py @@ -1,123 +1,153 @@ #!/usr/bin/env python3 -"""Open-Meteo weather client for AtlasAgent. No API key required. - -Usage: - python3 weather_client.py forecast --city "Tokyo" --days 7 - python3 weather_client.py forecast --lat 35.6762 --lon 139.6503 --days 5 - python3 weather_client.py current --city "Paris" -""" - +"""Date-aware Open-Meteo forecasts and current conditions. No API key.""" import argparse +from datetime import date, datetime, timedelta, timezone import json +import math import sys import urllib.parse -import urllib.request +from zoneinfo import ZoneInfo + +from http_client import APIError, fetch_json GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +DAILY_FIELDS = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max" +WMO_CODES = {0: "晴", 1: "大部晴朗", 2: "局部多云", 3: "多云", 45: "雾", 48: "雾凇", 51: "小毛毛雨", 53: "毛毛雨", 55: "大毛毛雨", 56: "冻毛毛雨", 57: "强冻毛毛雨", 61: "小雨", 63: "中雨", 65: "大雨", 66: "冻雨", 67: "强冻雨", 71: "小雪", 73: "中雪", 75: "大雪", 77: "雪粒", 80: "小阵雨", 81: "阵雨", 82: "大阵雨", 85: "阵雪", 86: "强阵雪", 95: "雷暴", 96: "雷暴伴冰雹", 99: "雷暴伴大冰雹"} + + +def geocode(city, country=None): + params = {"name": city, "count": 10, "language": "en", "format": "json"} + if country: + params["countryCode"] = country.upper() + results = fetch_json(GEOCODE_URL + "?" + urllib.parse.urlencode(params)).get("results") or [] + if not isinstance(results, list) or any(not isinstance(r, dict) for r in results): + raise APIError("Malformed geocoding results") + for result in results: + if any(not isinstance(result.get(key), (int, float)) or not math.isfinite(result[key]) for key in ("latitude", "longitude")): + raise APIError("Geocoding result is missing coordinates") + if country: + results = [r for r in results if r.get("country_code", "").upper() == country.upper()] + exact = [r for r in results if r.get("name", "").casefold() == city.casefold()] + results = exact or results + # Deduplicate aliases for the same coordinates, without guessing between towns. + results = list({(r["latitude"], r["longitude"]): r for r in results}.values()) + if not results: + raise ValueError("City not found: " + city) + if len(results) != 1: + choices = "; ".join("{}, {}, {} ({},{})".format(r.get("name"), r.get("admin1", ""), r.get("country", ""), r["latitude"], r["longitude"]) for r in results) + raise ValueError("Ambiguous city; specify --country or --lat/--lon: " + choices) + r = results[0] + return {"name": r.get("name", city), "country": r.get("country", ""), "lat": r["latitude"], "lon": r["longitude"], "timezone": r.get("timezone") or "UTC"} -def fetch_json(url: str) -> dict: - req = urllib.request.Request(url, headers={"User-Agent": "AtlasAgent/1.0"}) - with urllib.request.urlopen(req, timeout=15) as resp: - return json.loads(resp.read().decode()) +def local_today(tz): + return datetime.now(ZoneInfo(tz) if tz != "auto" else timezone.utc).date() -def geocode(city: str) -> dict: - params = urllib.parse.urlencode({"name": city, "count": 1, "language": "en", "format": "json"}) - data = fetch_json(f"{GEOCODE_URL}?{params}") - results = data.get("results") or [] - if not results: - raise SystemExit(f"City not found: {city}") - r = results[0] - return { - "name": r.get("name"), - "country": r.get("country"), - "lat": r["latitude"], - "lon": r["longitude"], - "timezone": r.get("timezone", "UTC"), - } - - -def forecast(lat: float, lon: float, days: int, timezone: str = "auto") -> dict: - params = urllib.parse.urlencode({ - "latitude": lat, - "longitude": lon, - "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max", - "timezone": timezone, - "forecast_days": min(days, 16), - }) - return fetch_json(f"{FORECAST_URL}?{params}") - - -WMO_CODES = { - 0: "晴", 1: "大部晴朗", 2: "局部多云", 3: "多云", - 45: "雾", 48: "雾凇", 51: "小毛毛雨", 53: "毛毛雨", 55: "大毛毛雨", - 61: "小雨", 63: "中雨", 65: "大雨", 71: "小雪", 73: "中雪", 75: "大雪", - 80: "小阵雨", 81: "阵雨", 82: "大阵雨", 95: "雷暴", 96: "雷暴+小冰雹", 99: "雷暴+大冰雹", -} - - -def print_forecast(loc: dict, data: dict) -> None: - daily = data.get("daily", {}) - dates = daily.get("time", []) - print(f"📍 {loc['name']}, {loc.get('country', '')} ({loc['lat']:.2f}, {loc['lon']:.2f})") - print(f"🕐 Timezone: {data.get('timezone', loc.get('timezone'))}") - print() - print(f"{'日期':<12} {'天气':<10} {'最高':>6} {'最低':>6} {'降水mm':>8} {'降水%':>6} {'风速km/h':>10}") - print("-" * 62) - for i, d in enumerate(dates): - code = daily.get("weather_code", [0])[i] - wx = WMO_CODES.get(code, f"code={code}") - tmax = daily.get("temperature_2m_max", [None])[i] - tmin = daily.get("temperature_2m_min", [None])[i] - precip = daily.get("precipitation_sum", [0])[i] - prob = daily.get("precipitation_probability_max", [None])[i] - wind = daily.get("wind_speed_10m_max", [None])[i] - prob_s = f"{prob}%" if prob is not None else "-" - print(f"{d:<12} {wx:<10} {tmax:>5.0f}°C {tmin:>5.0f}°C {precip:>7.1f} {prob_s:>6} {wind:>9.0f}") +def forecast(lat, lon, days, timezone="auto", start=None, today=None): + if not 1 <= days <= 16: + raise ValueError("--days must be between 1 and 16; longer trips need climate reference, not a forecast") + today = today or local_today(timezone) + start = start or today + end = start + timedelta(days=days - 1) + if start < today or end > today + timedelta(days=15): + raise ValueError("Requested dates {} to {} are outside the forecast window {} to {}; use climate reference and recheck near departure".format(start, end, today, today + timedelta(days=15))) + params = {"latitude": lat, "longitude": lon, "daily": DAILY_FIELDS, "timezone": timezone, "start_date": start.isoformat(), "end_date": end.isoformat()} + data = fetch_json(FORECAST_URL + "?" + urllib.parse.urlencode(params)) + expected = [(start + timedelta(days=i)).isoformat() for i in range(days)] + if not isinstance(data.get("daily"), dict) or data["daily"].get("time") != expected: + raise APIError("Forecast date coverage does not match requested dates") + return data -def main(): - p = argparse.ArgumentParser(description="AtlasAgent Open-Meteo weather client") - sub = p.add_subparsers(dest="cmd", required=True) +def current(lat, lon, timezone="auto"): + params = {"latitude": lat, "longitude": lon, "current": "temperature_2m,weather_code,precipitation,wind_speed_10m", "timezone": timezone} + data = fetch_json(FORECAST_URL + "?" + urllib.parse.urlencode(params)) + if not isinstance(data.get("current"), dict) or not data["current"].get("time"): + raise APIError("Service did not return current conditions with a timestamp") + return data + + +def value_at(daily, key, index): + values = daily.get(key) or [] + if not isinstance(values, list): + return None + return values[index] if index < len(values) else None - fc = sub.add_parser("forecast", help="Daily forecast") - fc.add_argument("--city", help="City name for geocoding") - fc.add_argument("--lat", type=float) - fc.add_argument("--lon", type=float) - fc.add_argument("--days", type=int, default=7) - fc.add_argument("--json", action="store_true") - cur = sub.add_parser("current", help="Current conditions summary") - cur.add_argument("--city", required=True) +def fmt(value, suffix="", digits=0): + if not isinstance(value, (int, float)) or not math.isfinite(value): + return "暂无数据" + return ("{:." + str(digits) + "f}").format(value) + suffix - args = p.parse_args() - if args.cmd == "forecast": +def print_forecast(loc, data): + daily = data.get("daily", {}) + print("地点: {}, {} | 时区: {}".format(loc["name"], loc.get("country", ""), data.get("timezone", loc.get("timezone")))) + print("日期 | 天气 | 最高 | 最低 | 降水mm | 降水概率 | 风速km/h") + for i, d in enumerate(daily.get("time", [])): + code = value_at(daily, "weather_code", i) + cells = [d, WMO_CODES.get(code, "暂无数据" if code is None else "code=" + str(code)), fmt(value_at(daily, "temperature_2m_max", i), "°C"), fmt(value_at(daily, "temperature_2m_min", i), "°C"), fmt(value_at(daily, "precipitation_sum", i), digits=1), fmt(value_at(daily, "precipitation_probability_max", i), "%"), fmt(value_at(daily, "wind_speed_10m_max", i))] + print(" | ".join(cells)) + + +def iso_date(text): + try: + return date.fromisoformat(text) + except ValueError as exc: + raise argparse.ArgumentTypeError("Use a valid YYYY-MM-DD date") from exc + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + for name in ("forecast", "current"): + child = sub.add_parser(name) + child.add_argument("--city") + child.add_argument("--country", help="ISO two-letter country code") + child.add_argument("--lat", type=float) + child.add_argument("--lon", type=float) + child.add_argument("--timezone", help="IANA timezone (recommended for coordinates)") + child.add_argument("--json", action="store_true") + if name == "forecast": + child.add_argument("--from", dest="start", type=iso_date, help="First trip date, YYYY-MM-DD") + child.add_argument("--days", type=int, default=7) + args = parser.parse_args() + try: + if args.cmd == "forecast" and not 1 <= args.days <= 16: + raise ValueError("--days must be between 1 and 16") + if args.country and (len(args.country) != 2 or not args.country.isalpha()): + raise ValueError("--country must be a two-letter country code") + if args.city and (args.lat is not None or args.lon is not None): + raise ValueError("Choose --city or --lat/--lon") if args.city: - loc = geocode(args.city) + loc = geocode(args.city, args.country) elif args.lat is not None and args.lon is not None: - loc = {"name": f"{args.lat},{args.lon}", "country": "", "lat": args.lat, "lon": args.lon, "timezone": "auto"} + if not math.isfinite(args.lat) or not math.isfinite(args.lon) or not -90 <= args.lat <= 90 or not -180 <= args.lon <= 180: + raise ValueError("Latitude/longitude are out of range") + loc = {"name": "{},{}".format(args.lat, args.lon), "country": "", "lat": args.lat, "lon": args.lon, "timezone": "auto"} else: - raise SystemExit("Provide --city or --lat/--lon") - data = forecast(loc["lat"], loc["lon"], args.days, loc.get("timezone", "auto")) + raise ValueError("Provide --city or both --lat and --lon") + if args.timezone: + ZoneInfo(args.timezone) + loc["timezone"] = args.timezone + data = forecast(loc["lat"], loc["lon"], args.days, loc["timezone"], args.start) if args.cmd == "forecast" else current(loc["lat"], loc["lon"], loc["timezone"]) if args.json: - print(json.dumps({"location": loc, "forecast": data}, ensure_ascii=False, indent=2)) - else: + print(json.dumps({"status": "ok", "location": loc, args.cmd: data}, ensure_ascii=False, indent=2)) + elif args.cmd == "forecast": print_forecast(loc, data) - - elif args.cmd == "current": - loc = geocode(args.city) - data = forecast(loc["lat"], loc["lon"], 1, loc.get("timezone", "auto")) - daily = data.get("daily", {}) - d0 = daily.get("time", ["?"])[0] - code = daily.get("weather_code", [0])[0] - tmax = daily.get("temperature_2m_max", [None])[0] - tmin = daily.get("temperature_2m_min", [None])[0] - print(f"📍 {loc['name']}, {loc['country']}") - print(f"📅 {d0}: {WMO_CODES.get(code, code)}, {tmin:.0f}–{tmax:.0f}°C") + else: + now = data["current"] + print("{} | {} ({}) | {} | {}".format(loc["name"], now["time"], data.get("timezone", loc["timezone"]), WMO_CODES.get(now.get("weather_code"), "暂无数据"), fmt(now.get("temperature_2m"), "°C", 1))) + except (APIError, ValueError, KeyError, OverflowError) as exc: + if args.json: + print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False)) + else: + print("Weather error: " + str(exc), file=sys.stderr) + return 2 + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/skills/atlas-agent/SKILL.md b/skills/atlas-agent/SKILL.md index b41e3ba..ea0ef53 100644 --- a/skills/atlas-agent/SKILL.md +++ b/skills/atlas-agent/SKILL.md @@ -19,13 +19,13 @@ Orchestrates worldwide trip planning: multi-plan comparison, daily schedules, bu ## Install -If not installed yet, follow [docs/install.md](../../docs/install.md) or tell the user: +If not installed yet, follow `$ATLAS_ROOT/docs/install.md` or tell the user: ``` 帮我安装 AtlasAgent:https://raw.githubusercontent.com/wpfbcr/AtlasAgent/main/docs/install.md ``` -Verify: `bash "$ATLAS_ROOT/scripts/atlas-agent doctor"` +Verify: `bash "$ATLAS_ROOT/scripts/atlas-agent" doctor` ## When to Use @@ -36,7 +36,7 @@ Verify: `bash "$ATLAS_ROOT/scripts/atlas-agent doctor"` ## Required Inputs -Ask for missing fields before planning: +For a real booking plan, confirm missing fields before planning. For a demonstration or test, use user-approved assumptions and list them explicitly: | Required | Optional | |----------|----------| @@ -56,21 +56,30 @@ export ATLAS_SCRIPTS="$ATLAS_ROOT/scripts" export ATLAS_REF="$ATLAS_ROOT/skills/atlas-agent/references" ``` -Install: see [docs/install.md](../../docs/install.md) — **full repo required**, not skills/ alone. +Install: see `$ATLAS_ROOT/docs/install.md` — **full repo required**, not skills/ alone. ## Workflow ### Phase 0 — Profile -1. Passport nationality → read `visa-entry` skill +1. For international travel, passport nationality → read `visa-entry`; for domestic travel, state that visa analysis is not applicable 2. Dietary restrictions → read `dietary-global` skill 3. Booked tickets/hotels → treat as **fixed anchors** +### Phase 0.5 — Disambiguation + +Do not guess ambiguous names. Present options and **ask the user to choose**: + +- Location, transport, duration, ticket/booking needs +- Local + English names + +Examples: Paris (France vs Texas), Springfield (30+ US cities). + ### Phase 1 — Data (parallel) ```bash # Weather -python3 "$ATLAS_SCRIPTS/weather_client.py" forecast --city "" --days +python3 "$ATLAS_SCRIPTS/weather_client.py" forecast --lat --lon --timezone --from YYYY-MM-DD --days # Currency python3 "$ATLAS_SCRIPTS/currency_client.py" rates --base @@ -80,28 +89,21 @@ python3 "$ATLAS_SCRIPTS/currency_client.py" convert --from --to python3 "$ATLAS_SCRIPTS/holiday_check.py" --city "" --from YYYY-MM-DD --days ``` +Weather output must cover the exact trip dates. Outside the supported forecast window, label climate reference explicitly and recheck near departure. A network error or missing field is unavailable data, never a zero or sunny default. + **Geography** (first available): 1. `$MAPS_SCRIPT` if set (any geocoder/POI CLI, e.g. OpenStreetMap client) 2. Web search + `$ATLAS_REF/destinations/.md` 3. Never invent hotel/restaurant names -**Flights/hotels (optional L1):** trvl MCP or web search — see [mcp/recommended-servers.md](../../mcp/recommended-servers.md) +**Flights/hotels (optional L1):** trvl MCP or web search — see `$ATLAS_ROOT/mcp/recommended-servers.md` **Reviews (optional L1):** `local-intel` skill -### Phase 1.5 — Disambiguation - -Do not guess ambiguous names. Present options and **ask the user to choose**: - -- Location, transport, duration, ticket/booking needs -- Local + English names - -Examples: Paris (France vs Texas), Springfield (30+ US cities). - ### Phase 2 — Scheduling -1. Use `holiday_check.py` — weekdays, holidays, museum closure hints +1. Use `holiday_check.py` for weekdays and closure hints, then verify each scheduled venue’s specific opening calendar from official sources 2. Book major sights on **weekdays** when possible 3. Arrival day = half day; allow jet-lag buffer after eastbound flights 4. Multi-city: minimize backtracking (use distance/routing tools if available) @@ -125,6 +127,8 @@ Each plan includes: **Required sections:** must-eat list, 2–3 stays, budget table (budget-optimizer), transport decision (transport-global), document checklist (travel-documents), cautions. +**Final validation:** check exact day count, weekdays, opening days, sum of budget items, party-size division, currency labels, and budget constraints. Mark any unresolved information as unverified. Same-currency budgets need no exchange rate date. + **Meals:** every meal = restaurant + dishes with dietary tags (🟢🟡🔴⚠️) + price per person. **Pace:** only one "intense" day; others relaxed. Mark activity hours. diff --git a/skills/atlas-agent/references/destinations/tokyo.md b/skills/atlas-agent/references/destinations/tokyo.md index 3731689..80a8e66 100644 --- a/skills/atlas-agent/references/destinations/tokyo.md +++ b/skills/atlas-agent/references/destinations/tokyo.md @@ -71,8 +71,9 @@ - teamLab/吉卜力/迪士尼需提前预约 - 日本无小费文化 - 现金仍常用,备 ¥10000–20000 -- 中国护照 30 天免签(2024 起政策,出行前核实) +- 中国大陆普通护照一般赴日旅游须提前办理签证,停留期限按签证及入境许可;出行前核实适用于本人的要求 ## 数据来源 - 最后更新:2026-06-25 +- 日本签证信息核验:2026-10-10,[日本外务省中国公民签证说明](https://www.mofa.go.jp/j_info/visit/visa/topics/china.html);其他票价及资料仍为上面的参考日期 diff --git a/skills/atlas-agent/references/md2pdf.md b/skills/atlas-agent/references/md2pdf.md index 8b2276e..d162f8b 100644 --- a/skills/atlas-agent/references/md2pdf.md +++ b/skills/atlas-agent/references/md2pdf.md @@ -1,39 +1,16 @@ -# Markdown → PDF 转换 +# Markdown → PDF 导出 -AtlasAgent 行程导出为可打印 PDF。 - -## 依赖 - -```bash -pip3 install fpdf2 -``` - -## 字体 - -| 平台 | 路径 | -|------|------| -| macOS | `/System/Library/Fonts/Supplemental/Arial Unicode.ttf` | -| Linux | `/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc` | -| Windows | `C:\Windows\Fonts\msyh.ttc` | - -脚本自动检测可用字体。 - -## 用法 +安装可选依赖: ```bash -python3 ~/AtlasAgent/scripts/md2pdf.py examples/tokyo-7days-zh.md -python3 ~/AtlasAgent/scripts/md2pdf.py examples/ +python3 -m pip install -r "$ATLAS_ROOT/requirements-pdf.txt" +python3 "$ATLAS_ROOT/scripts/md2pdf.py" itinerary.md ``` -## 支持格式 +支持连续正文、标题、引用、列表、代码和自动换行的表格。表格跨页重复表头;长内容完整保留,不以字符数截断。来源链接的 URL 保留到 PDF 中,饮食标签转换为文字,星级转换为可显示的星号。 -- `#` 标题层级 -- 表格(交替行底色) -- 列表、引用、代码块 -- 粗体/链接(链接转为纯文本) +字体自动查找 macOS、Linux 和 Windows 的 CJK 字体。可通过 `ATLAS_FONT` 指定 TTF/TTC 文件。TrueType字体最低fpdf2版本为2.8.4;CFF字体(如Noto Sans CJK)要求fpdf2≥2.8.9和Python≥3.10。Python3.9的Linux环境可安装fonts-wqy-zenhei使用TrueType字体;旧 PyFPDF 与 fpdf2 共用导入名,建议使用独立虚拟环境。 -## 限制 +限制:不支持内嵌图片或完整 HTML。单行表格内容如果高于整页可用高度,导出会报错,需在源文档拆成多行;不会静默丢弃文字。缺少字形时显示 Unicode 编码占位,便于核查。 -- Emoji 可能不渲染(非关键) -- 不支持内嵌图片 -- 表格单元格 >45 字符截断 +验证:检查连续正文、长表格末尾、跨页表头、语义标签和来源链接,再渲染每页查看布局。 diff --git a/skills/budget-optimizer/SKILL.md b/skills/budget-optimizer/SKILL.md index 5ae1a55..fde8589 100644 --- a/skills/budget-optimizer/SKILL.md +++ b/skills/budget-optimizer/SKILL.md @@ -16,7 +16,7 @@ Standard budget tables for atlas-agent plans. ## Setup -Use `currency` skill for FX. Note rate date on every table. +Use `currency` skill for FX. For conversions, note the returned rate date. For same-currency budgets, label them `无需换汇` rather than inventing a date. Validate subtotal, contingency percentage, total, per-person division, and deviation from the requested budget. ```bash export ATLAS_ROOT="${ATLAS_ROOT:-$HOME/AtlasAgent}" diff --git a/skills/currency/SKILL.md b/skills/currency/SKILL.md index d5f8c9a..2c8813c 100644 --- a/skills/currency/SKILL.md +++ b/skills/currency/SKILL.md @@ -33,7 +33,8 @@ python3 "$ATLAS_SCRIPTS/currency_client.py" convert 8000 --from CNY --to JPY EUR ## Rules - Budget tables show **local currency + user currency** -- Always note `汇率日期: YYYY-MM-DD` +- Foreign-currency conversions note the returned rate date; same-currency budgets state `无需换汇` and have no fabricated rate date. +- Repeated `--to` arguments are combined. Use `--json` for machine-readable budget input. - Airport/hotel FX rates are worse than ECB reference — mention in cautions ## Common Codes diff --git a/skills/transport-global/SKILL.md b/skills/transport-global/SKILL.md index a8796b9..1b8f0c3 100644 --- a/skills/transport-global/SKILL.md +++ b/skills/transport-global/SKILL.md @@ -36,7 +36,7 @@ See [references/transport-guides.md](references/transport-guides.md). ## Optional L1 -Real-time prices via trvl MCP — [mcp/recommended-servers.md](../../mcp/recommended-servers.md) +Real-time prices via trvl MCP — `$ATLAS_ROOT/mcp/recommended-servers.md` ## Pitfalls diff --git a/skills/travel-documents/SKILL.md b/skills/travel-documents/SKILL.md index 1f94de5..4304540 100644 --- a/skills/travel-documents/SKILL.md +++ b/skills/travel-documents/SKILL.md @@ -16,8 +16,8 @@ Pre-departure checklist for every international plan. ## Essentials -- [ ] Passport (≥6 months validity, ≥2 blank pages) -- [ ] Visa / ESTA / eTA / EVW (see visa-entry) +- [ ] Passport (validity and blank pages satisfy the destination’s verified requirements) +- [ ] Visa / ESTA / eTA / ETA where applicable (see visa-entry) - [ ] IDP if driving (see transport-global) - [ ] Flight/hotel confirmations (print + digital) - [ ] Payment cards + some cash (USD/EUR backup) diff --git a/skills/visa-entry/SKILL.md b/skills/visa-entry/SKILL.md index ac4eba3..ae643e1 100644 --- a/skills/visa-entry/SKILL.md +++ b/skills/visa-entry/SKILL.md @@ -45,8 +45,7 @@ Visa and entry analysis for international trips. **Policies change — always no ## Universal Rules -- Passport valid ≥6 months at entry (most countries) -- ≥2 blank visa pages +- Check the destination’s actual passport validity and blank-page requirements; report the specific requirement instead of treating six months/two pages as universal - Schengen: 90/180 day rule - US ESTA / Canada eTA / UK ETA where applicable - Transit may still require a visa diff --git a/skills/visa-entry/references/visa-quick-ref.md b/skills/visa-entry/references/visa-quick-ref.md index ad46bda..b815d8e 100644 --- a/skills/visa-entry/references/visa-quick-ref.md +++ b/skills/visa-entry/references/visa-quick-ref.md @@ -1,58 +1,30 @@ -# 签证速查参考(2026-06) - -> **必须 web 核实**。本表仅作 Phase 1 快速判断,输出时标注查询日期。 - -## 中国护照(PRC)热门目的地 - -| 目的地 | 类型 | 停留 | 备注 | -|--------|------|------|------| -| 日本 | 免签 | 30天 | 2024 起试行,政策可能调整 | -| 新加坡 | 免签 | 30天 | | -| 泰国 | 免签 | 60天 | | -| 马来西亚 | 免签 | 30天 | | -| 阿联酋 | 免签 | 30天 | | -| 韩国 | 需签证 | - | 个别情况免签,查最新 | -| 美国 | B1/B2 签证 | - | 需面签 | -| 加拿大 | 需签证 | - | 或 eTA(仅部分身份) | -| 英国 | 需签证 | - | | -| 申根区 | 申根C签 | 90/180天 | 法/德/意/西等 | -| 澳大利亚 | 需签证 | - | 在线申请 | -| 新西兰 | 需签证/ NZeTA | - | 视身份 | - -## 美国护照热门目的地 - -| 目的地 | 类型 | 备注 | -|--------|------|------| -| 欧盟申根 | 免签 | 90/180天 | -| 英国 | 免签 | 6个月 | -| 日本 | 免签 | 90天 | -| 澳大利亚 | eVisitor | 在线 | -| 中国 | 需签证 | L/M 等 | -| 俄罗斯 | 需签证 | | - -## 电子旅行授权 - -| 名称 | 适用 | 费用 | 有效期 | -|------|------|------|--------| -| ESTA | 38国公民赴美 | $21 | 2年 | -| eTA | 免签国赴加 | CAD $7 | 5年 | -| ETA UK | 免签国赴英 | £10 | 2年 | -| eVisitor | 赴澳 | 免费 | 12个月 | -| K-ETA | 免签国赴韩 | 约$10 | 2年 | - -## 通用材料清单 - -- 护照原件(≥6个月有效) -- 签证申请表 + 照片 -- 往返机票预订单 -- 酒店预订单 -- 银行流水(3–6个月) -- 旅行保险(申根强制) -- 在职证明 / 学生证 - -## 官方查询入口 - -- 中国领事服务网:https://cs.mfa.gov.cn/ -- 美国国务院:https://travel.state.gov/ -- 欧盟 ETIAS(2025+):https://travel-europe.europa.eu/ -- IATA Travel Centre:https://www.iatatravelcentre.com/ +# 签证官方查询入口 + +按用户的护照签发地区、居住地、目的地、过境地和出行日期查询。各国停留天数、费用和电子授权要求会变化,此文件提供查询入口;最终结论引用具体政策页面及本次查询日期。 + +## 已核验案例 + +中国大陆普通护照一般赴日旅游须提前办理签证;停留期限按签证及入境许可。来源:[日本外务省中国公民签证说明](https://www.mofa.go.jp/j_info/visit/visa/topics/china.html),核验日期 2026-10-10。中国大陆普通护照、香港/澳门特区护照和台湾地区证件应分别核查。 + +## 查询路由 + +| 目的地/业务 | 官方入口 | 核查内容 | +|---|---|---| +| 日本 | https://www.mofa.go.jp/j_info/visit/visa/ | 护照类别、旅游签证、适用居住地 | +| 新加坡 | https://www.ica.gov.sg/enter-transit-depart | 入境、签证及申报 | +| 泰国 | https://www.thaievisa.go.th/ | 签证及适用免签条件 | +| 马来西亚 | https://www.imi.gov.my/ | 签证、入境及申报 | +| 美国 | https://travel.state.gov/ | 签证、免签计划及过境条件 | +| 美国 ESTA | https://esta.cbp.dhs.gov/ | 资格、当前费用及有效期 | +| 加拿大 | https://www.canada.ca/en/immigration-refugees-citizenship/services/visit-canada.html | 签证/eTA 的个人资格 | +| 英国 | https://www.gov.uk/check-uk-visa | 签证/ETA 及适用条件 | +| 申根 | https://home-affairs.ec.europa.eu/policies/schengen/visa-policy_en | 签证政策,再查具体使领馆 | +| 澳大利亚 | https://immi.homeaffairs.gov.au/visas/getting-a-visa/visa-finder | 按护照选择 ETA/eVisitor/访客签证 | +| 新西兰 | https://www.immigration.govt.nz/ | 签证/NZeTA 及适用条件 | +| 中国 | https://cs.mfa.gov.cn/ | 领事、护照及出入境查询 | + +表内入口仅用于定位官方政策,不代表已逐项核验当前具体政策。查询入口不能替代支持结论的具体页面。费用以官方申请页面为准,不使用历史固定金额。 + +## 完成标准 + +输出前确认签证类型、允许停留期限、护照有效期、空白页要求、过境条件及政策查询日期;未取得足够资料时标记待确认。国内行程的签证分析标为不适用。 diff --git a/skills/weather/SKILL.md b/skills/weather/SKILL.md index ce73f18..288a8f6 100644 --- a/skills/weather/SKILL.md +++ b/skills/weather/SKILL.md @@ -26,12 +26,16 @@ export ATLAS_SCRIPTS="$ATLAS_ROOT/scripts" ## Commands ```bash -python3 "$ATLAS_SCRIPTS/weather_client.py" forecast --city "Tokyo" --days 7 +python3 "$ATLAS_SCRIPTS/weather_client.py" forecast --city "Tokyo" --country JP --days 7 python3 "$ATLAS_SCRIPTS/weather_client.py" forecast --lat 48.8566 --lon 2.3522 --days 5 -python3 "$ATLAS_SCRIPTS/weather_client.py" current --city "Paris" -python3 "$ATLAS_SCRIPTS/weather_client.py" forecast --city "Sydney" --days 10 --json +python3 "$ATLAS_SCRIPTS/weather_client.py" current --city "Paris" --country FR +python3 "$ATLAS_SCRIPTS/weather_client.py" forecast --city "Sydney" --country AU --days 10 --json ``` +For travel dates, include `--from YYYY-MM-DD` and the exact number of trip days. The client rejects ranges outside the next 16 local dates instead of substituting today's forecast. For ambiguous city names, specify coordinates and `--timezone` after confirming the location; the client prints candidate locations instead of selecting the first. + +`current` returns current conditions and their timestamp. `--json` is available for both commands. Missing observations appear as `暂无数据` in text; service failures have a nonzero exit code and an explicit error. + ## Output Use - Summarize in itinerary weather section diff --git a/tests/test_chengdu_scenario.py b/tests/test_chengdu_scenario.py new file mode 100644 index 0000000..0649e40 --- /dev/null +++ b/tests/test_chengdu_scenario.py @@ -0,0 +1,59 @@ +"""Validate the delivered five-day/two-person scenario against its constraints.""" +from datetime import date, timedelta +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class ChengduScenarioTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.scenario = json.loads((ROOT / 'examples/chengdu-5days.json').read_text()) + cls.markdown = (ROOT / 'examples/chengdu-5days-zh.md').read_text() + + def test_dates_party_size_and_only_one_intense_day(self): + data = self.scenario + self.assertEqual(data['party_size'], 2) + days = data['activities'] + self.assertEqual(len(days), 5) + expected = [(date(2026, 10, 19) + timedelta(days=i)).isoformat() for i in range(5)] + self.assertEqual([a['date'] for a in days], expected) + self.assertEqual(data['weather_dates'], expected) + self.assertEqual(sum(a['intense'] for a in days), 1) + for day in days: + self.assertIn(day['date'], self.markdown) + if '成都博物馆' in day['venues']: + self.assertNotEqual(date.fromisoformat(day['date']).weekday(), data['museum_closed_weekday']) + + def test_budget_arithmetic_and_five_day_meal_allowance(self): + data = self.scenario + subtotal = sum(item['amount'] for item in data['costs']) + self.assertEqual(subtotal, data['subtotal']) + self.assertEqual(round(subtotal * data['contingency_rate'], 2), data['contingency']) + self.assertEqual(subtotal + data['contingency'], data['total']) + self.assertEqual(data['total'] / data['party_size'], data['per_person']) + self.assertEqual(data['budget'] - data['total'], data['headroom']) + self.assertLessEqual(data['total'], data['budget']) + meals = data['meals'] + self.assertEqual(len(meals), 15) + for day in range(1, 6): + self.assertEqual({m['meal'] for m in meals if m['day'] == day}, {'早餐', '午餐', '晚餐'}) + # Explicitly budget 30 CNY per person in addition to meal estimates for snacks/water. + meal_total = (sum(m['per_person_estimate'] for m in meals) + 30) * data['party_size'] + food = next(c['amount'] for c in data['costs'] if c['name'].startswith('餐饮')) + self.assertEqual(meal_total, food) + self.assertIn('CNY4279', self.markdown) + self.assertIn('CNY3916', self.markdown) + + def test_unbooked_and_estimated_information_is_disclosed(self): + self.assertTrue(self.scenario['prices_are_estimates']) + self.assertIn('不包含出发城市往返成都的大交通', self.markdown) + self.assertIn('未预订', self.markdown) + self.assertIn('人工编排内容没有冒充脚本直接生成的文本', self.markdown) + self.assertIn('无需换汇', self.markdown) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_installation.py b/tests/test_installation.py new file mode 100644 index 0000000..0300d1f --- /dev/null +++ b/tests/test_installation.py @@ -0,0 +1,230 @@ +"""Installer regression tests; all writes stay inside temporary directories.""" + +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class InstallerTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="atlas installation ") + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name) + self.repo = self.base / "repo with spaces" + shutil.copytree(ROOT / "scripts", self.repo / "scripts") + shutil.copytree(ROOT / "skills", self.repo / "skills") + self.user = self.base / "isolated user" + self.user.mkdir() + self.rc = self.user / ".zshrc" + self.rc.write_text("# unrelated user config\n") + self.dest = self.user / ".cursor/skills" + self.env = os.environ.copy() + self.env.update(ATLAS_USER_HOME=str(self.user), ATLAS_ROOT=str(self.repo)) + + def run_install(self, *args): + return subprocess.run( + ["bash", str(self.repo / "scripts/install.sh"), *args], + env=self.env, capture_output=True, text=True, timeout=20, + ) + + def existing_skill(self, name="weather", dest=None): + target = (dest or self.dest) / name + target.mkdir(parents=True) + (target / "custom.txt").write_text("user content\n") + return target + + def fake_tool(self, name, body): + bin_dir = self.base / "bin" + bin_dir.mkdir(exist_ok=True) + tool = bin_dir / name + tool.write_text("#!/bin/sh\n" + body + "\n") + tool.chmod(0o755) + self.env["PATH"] = str(bin_dir) + os.pathsep + os.environ["PATH"] + + def assert_no_stage(self): + self.assertEqual(list(self.dest.glob(".atlas-stage.*")), []) + + def test_fresh_install_and_repeat_are_idempotent(self): + first = self.run_install("cursor") + self.assertEqual(first.returncode, 0, first.stderr) + installed = sorted(p.name for p in self.dest.iterdir()) + expected = sorted(p.name for p in (self.repo / "skills").iterdir()) + self.assertEqual(installed, expected) + before = (self.dest / "weather/SKILL.md").stat().st_mtime_ns + rc_before = self.rc.read_text() + second = self.run_install("cursor") + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual((self.dest / "weather/SKILL.md").stat().st_mtime_ns, before) + self.assertEqual(self.rc.read_text(), rc_before) + self.assertFalse((self.dest / ".atlas-backups").exists()) + self.assert_no_stage() + + def test_conflict_preserves_every_target_and_shell_config(self): + existing = self.existing_skill() + result = self.run_install("cursor") + self.assertNotEqual(result.returncode, 0) + self.assertIn("--overwrite", result.stderr) + self.assertEqual((existing / "custom.txt").read_text(), "user content\n") + self.assertFalse((self.dest / "atlas-agent").exists()) + self.assertEqual(self.rc.read_text(), "# unrelated user config\n") + self.assert_no_stage() + + def test_all_preflights_later_agents_before_any_install(self): + existing = self.existing_skill(dest=self.user / ".agents/skills") + result = self.run_install("all") + self.assertNotEqual(result.returncode, 0) + self.assertTrue((existing / "custom.txt").exists()) + self.assertFalse(self.dest.exists()) + self.assertFalse((self.user / ".claude").exists()) + self.assertEqual(self.rc.read_text(), "# unrelated user config\n") + + def test_overwrite_retains_exact_old_content_in_unique_backups(self): + existing = self.existing_skill() + result = self.run_install("cursor", "--overwrite") + self.assertEqual(result.returncode, 0, result.stderr) + backups = list((self.dest / ".atlas-backups").glob("weather.*/weather")) + self.assertEqual(len(backups), 1) + self.assertEqual((backups[0] / "custom.txt").read_text(), "user content\n") + self.assertIn(str(backups[0]), result.stdout) + self.assertFalse((existing / "custom.txt").exists()) + self.assertEqual((existing / "SKILL.md").read_bytes(), (self.repo / "skills/weather/SKILL.md").read_bytes()) + (existing / "custom.txt").write_text("second customization\n") + second = self.run_install("cursor", "--overwrite") + self.assertEqual(second.returncode, 0, second.stderr) + backups = list((self.dest / ".atlas-backups").glob("weather.*/weather")) + self.assertEqual(len(backups), 2) + self.assertEqual({(b / "custom.txt").read_text() for b in backups}, {"user content\n", "second customization\n"}) + self.assert_no_stage() + + def test_copy_failure_preserves_previous_skill(self): + existing = self.existing_skill("atlas-agent") + self.fake_tool("cp", "exit 9") + result = self.run_install("cursor", "--overwrite") + self.assertNotEqual(result.returncode, 0) + self.assertEqual((existing / "custom.txt").read_text(), "user content\n") + self.assertFalse((self.dest / ".atlas-backups").exists()) + self.assert_no_stage() + + def test_final_move_failure_restores_previous_skill(self): + existing = self.existing_skill("atlas-agent") + self.fake_tool("mv", 'case "$2" in *"/.atlas-stage."*) exit 9 ;; esac\nexec /bin/mv "$@"') + result = self.run_install("cursor", "--overwrite") + self.assertNotEqual(result.returncode, 0) + self.assertEqual((existing / "custom.txt").read_text(), "user content\n") + self.assertFalse((existing / "SKILL.md").exists()) + self.assert_no_stage() + + def test_symlink_conflict_keeps_external_target_intact(self): + external = self.base / "external skill" + external.mkdir() + (external / "custom.txt").write_text("external content") + self.dest.mkdir(parents=True) + (self.dest / "weather").symlink_to(external, target_is_directory=True) + blocked = self.run_install("cursor") + self.assertNotEqual(blocked.returncode, 0) + self.assertTrue((self.dest / "weather").is_symlink()) + replaced = self.run_install("cursor", "--overwrite") + self.assertEqual(replaced.returncode, 0, replaced.stderr) + self.assertEqual((external / "custom.txt").read_text(), "external content") + backup = next((self.dest / ".atlas-backups").glob("weather.*/weather")) + self.assertTrue(backup.is_symlink()) + self.assertFalse((self.dest / "weather").is_symlink()) + + def test_invalid_options_do_not_write(self): + for args in [("bogus",), ("cursor", "--overwite")]: + with self.subTest(args=args): + result = self.run_install(*args) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.dest.exists()) + self.assertEqual(self.rc.read_text(), "# unrelated user config\n") + + def run_cli(self, *args): + return subprocess.run( + ["bash", str(self.repo / "scripts/atlas-agent"), "install", *args], + env=self.env, text=True, capture_output=True, timeout=20, + ) + + def test_cli_forwards_overwrite_and_does_not_install_dependencies(self): + self.existing_skill() + self.fake_tool("python3", "exit 0") + blocked = self.run_cli("--agent=cursor") + self.assertNotEqual(blocked.returncode, 0) + success = self.run_cli("--agent=cursor", "--overwrite") + self.assertEqual(success.returncode, 0, success.stderr) + self.assertTrue((self.dest / "weather/SKILL.md").exists()) + self.assertEqual(len(list((self.dest / ".atlas-backups").glob("weather.*/weather"))), 1) + + def test_safe_and_dry_run_do_not_modify_anything(self): + self.existing_skill() + for flag in ["--safe", "--dry-run"]: + with self.subTest(flag=flag): + result = self.run_cli("--agent=cursor", "--overwrite", flag) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.dest / "weather/custom.txt").exists()) + self.assertFalse((self.dest / "atlas-agent").exists()) + self.assertFalse((self.dest / ".atlas-backups").exists()) + self.assertEqual(self.rc.read_text(), "# unrelated user config\n") + + def test_no_env_keeps_shell_configuration_unchanged(self): + original = self.rc.read_text() + result = self.run_install("cursor", "--no-env") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.rc.read_text(), original) + self.assertTrue((self.dest / "weather/SKILL.md").exists()) + + def test_safe_no_env_instructions_propagate_the_option(self): + result = self.run_cli("--agent=cursor", "--env=none", "--safe") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--no-env", result.stdout) + self.assertFalse(self.dest.exists()) + + def test_cli_rejects_invalid_agent_and_typo(self): + for args in [("--agent=bogus", "--dry-run"), ("--overwite",)]: + result = self.run_cli(*args) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.dest.exists()) + + +class DocumentationTests(unittest.TestCase): + def test_update_and_agent_commands_run_with_a_space_in_root(self): + with tempfile.TemporaryDirectory(prefix="atlas docs ") as folder: + root = Path(folder) + scripts = root / "scripts" + scripts.mkdir() + log = root / "calls.txt" + (scripts / "atlas-agent").write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$ATLAS_TEST_LOG"\n') + bin_dir = root / "bin" + bin_dir.mkdir() + for name in ["git", "pip3"]: + tool = bin_dir / name + tool.write_text("#!/bin/sh\nexit 0\n") + tool.chmod(0o755) + env = os.environ.copy() + env.update(ATLAS_ROOT=str(root), ATLAS_TEST_LOG=str(log), PATH=str(bin_dir) + os.pathsep + env["PATH"]) + commands = [] + for name in ["docs/update.md", "AGENTS.md"]: + commands.extend(re.findall(r"```bash\n(.*?)```", (ROOT / name).read_text(), re.S)) + skill = (ROOT / "skills/atlas-agent/SKILL.md").read_text() + commands.extend(re.findall(r"Verify: `(.*?)`", skill)) + for command in commands: + with self.subTest(command=command): + syntax = subprocess.run(["bash", "-n"], input=command, capture_output=True, text=True) + self.assertEqual(syntax.returncode, 0, syntax.stderr) + result = subprocess.run(["bash", "-eu"], input=command, env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + calls = log.read_text().splitlines() + self.assertIn("install --env=auto --agent=all", calls) + self.assertIn("install --env=auto --agent=all --overwrite", calls) + self.assertIn("doctor", calls) + self.assertIn("check-update", calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..e803407 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,294 @@ +"""Offline regressions for forecast, FX, diagnostics, environment and export.""" +import contextlib +from datetime import date +import importlib.metadata +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import urllib.error +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'scripts')) +import http_client +import weather_client as weather +import currency_client as currency +import holiday_check as calendar +import doctor +import environment + + +class RequestTests(unittest.TestCase): + def response(self, text): + response = unittest.mock.MagicMock() + response.__enter__.return_value.read.return_value = text.encode() + return response + + def test_retry_then_success_is_bounded(self): + with patch('http_client.urllib.request.urlopen', side_effect=[TimeoutError(), self.response('{"ok": true}')]) as request, patch('http_client.time.sleep') as delay: + self.assertEqual(http_client.fetch_json('https://example.test'), {'ok': True}) + self.assertEqual(request.call_count, 2) + self.assertEqual(delay.call_count, 1) + + def test_failure_has_exact_retry_limit(self): + with patch('http_client.urllib.request.urlopen', side_effect=TimeoutError()), patch('http_client.time.sleep') as delay: + with self.assertRaises(http_client.APIError): + http_client.fetch_json('https://example.test') + self.assertEqual(delay.call_count, 2) + + def test_bad_request_is_not_retried(self): + error = urllib.error.HTTPError('https://example.test', 400, 'bad', None, None) + with patch('http_client.urllib.request.urlopen', side_effect=error) as request: + with self.assertRaises(http_client.APIError): + http_client.fetch_json('https://example.test') + self.assertEqual(request.call_count, 1) + + def test_invalid_and_nonobject_json_fail_cleanly(self): + for body in ['not json', '[]', '{"error": true,"reason":"bad date"}']: + with self.subTest(body=body), patch('http_client.urllib.request.urlopen', return_value=self.response(body)): + with self.assertRaises(http_client.APIError): + http_client.fetch_json('https://example.test') + + +class WeatherTests(unittest.TestCase): + def test_trip_dates_are_requested_exactly(self): + days = ['2026-10-19', '2026-10-20', '2026-10-21', '2026-10-22', '2026-10-23'] + with patch.object(weather, 'fetch_json', return_value={'daily': {'time': days}}) as request: + weather.forecast(30.67, 104.07, 5, 'Asia/Shanghai', date(2026, 10, 19), today=date(2026, 10, 11)) + url = request.call_args.args[0] + self.assertIn('start_date=2026-10-19', url) + self.assertIn('end_date=2026-10-23', url) + + def test_out_of_range_and_invalid_days_do_not_call_service(self): + for start, days in [(date(2026, 10, 10), 1), (date(2026, 10, 26), 2), (date(2026, 10, 11), 0), (date(2026, 10, 11), 17)]: + with self.subTest(start=start, days=days), patch.object(weather, 'fetch_json') as request: + with self.assertRaises(ValueError): + weather.forecast(0, 0, days, start=start, today=date(2026, 10, 11)) + request.assert_not_called() + + def test_missing_and_wrong_dates_are_rejected(self): + for data in [{}, {'daily': None}, {'daily': {'time': ['2026-10-12']}}]: + with patch.object(weather, 'fetch_json', return_value=data): + with self.assertRaises(http_client.APIError): + weather.forecast(0, 0, 1, start=date(2026, 10, 11), today=date(2026, 10, 11)) + + def test_null_and_missing_columns_have_visible_placeholder(self): + output = io.StringIO() + loc = {'name': 'Chengdu', 'country': 'China', 'timezone': 'Asia/Shanghai'} + with contextlib.redirect_stdout(output): + weather.print_forecast(loc, {'daily': {'time': ['2026-10-19', '2026-10-20'], 'temperature_2m_max': [None]}}) + self.assertIn('2026-10-20', output.getvalue()) + self.assertIn('暂无数据', output.getvalue()) + self.assertNotIn('晴', output.getvalue()) + self.assertNotIn('0.0', output.getvalue()) + + def test_current_uses_current_fields_and_timestamp(self): + with patch.object(weather, 'fetch_json', return_value={'current': {'time': '2026-10-11T12:00', 'temperature_2m': 20}}) as request: + result = weather.current(30.67, 104.07, 'Asia/Shanghai') + self.assertIn('current=temperature_2m', request.call_args.args[0]) + self.assertNotIn('daily=', request.call_args.args[0]) + self.assertEqual(result['current']['time'], '2026-10-11T12:00') + + def test_ambiguous_city_is_not_silently_chosen(self): + results = [{'name': 'Paris', 'country_code': country, 'latitude': lat, 'longitude': 1, 'timezone': 'UTC'} for country, lat in [('FR', 48), ('US', 33)]] + with patch.object(weather, 'fetch_json', return_value={'results': results}): + with self.assertRaisesRegex(ValueError, 'Ambiguous'): + weather.geocode('Paris') + self.assertEqual(weather.geocode('Paris', 'FR')['lat'], 48) + + def test_cli_network_failure_returns_json_without_traceback(self): + with patch.object(sys, 'argv', ['weather', 'current', '--lat', '0', '--lon', '0', '--json']), patch.object(weather, 'fetch_json', side_effect=http_client.APIError('offline')): + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(weather.main(), 2) + self.assertEqual(json.loads(output.getvalue())['status'], 'error') + + +class CurrencyTests(unittest.TestCase): + def test_repeated_targets_and_identity_are_preserved(self): + data = {'date': '2026-10-09', 'base': 'CNY', 'rates': {'JPY': 20, 'USD': .14}} + with patch.object(currency, 'fetch_json', return_value=data) as request, patch.object(sys, 'argv', ['currency', 'convert', '100', '--from', 'CNY', '--to', 'JPY', '--to', 'USD', 'CNY', '--json']): + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(currency.main(), 0) + self.assertEqual(json.loads(output.getvalue())['rates'], {'JPY': 2000, 'USD': 14, 'CNY': 100}) + self.assertIn('to=JPY%2CUSD', request.call_args.args[0]) + + def test_identity_requires_no_network_or_fabricated_rate_date(self): + with patch.object(currency, 'fetch_json') as request: + result = currency.conversion(5000, 'CNY', ['CNY']) + self.assertEqual(result['rates'], {'CNY': 5000}) + self.assertIsNone(result['date']) + request.assert_not_called() + + def test_unit_rates_keep_small_exchange_rates(self): + with patch.object(currency, 'fetch_json', return_value={'date': '2026-10-09', 'base': 'JPY', 'rates': {'USD': .0061}}): + result = currency.conversion(1, 'JPY', ['USD'], round_results=False) + self.assertEqual(result['rates']['USD'], .0061) + + def test_invalid_amounts_rejected_before_network(self): + for amount in [-1, float('inf'), float('nan')]: + with patch.object(currency, 'fetch_json') as request: + with self.assertRaises(ValueError): + currency.conversion(amount, 'CNY', ['USD']) + request.assert_not_called() + + def test_missing_rates_and_dates_are_errors(self): + for data in [{'rates': {}}, {'date': 'bad', 'base': 'CNY', 'rates': {'USD': .14}}, {'date': '2026-10-09', 'base': 'CNY', 'rates': {}}]: + with patch.object(currency, 'fetch_json', return_value=data): + with self.assertRaises(http_client.APIError): + currency.conversion(100, 'CNY', ['USD']) + + +class CalendarTests(unittest.TestCase): + def test_five_chengdu_dates_and_monday_warning(self): + result = calendar.analyze(date(2026, 10, 19), 5, '成都') + self.assertEqual(len(result['days']), 5) + self.assertEqual(result['days'][0]['weekday'], '周一') + self.assertEqual(result['days'][-1]['date'], '2026-10-23') + self.assertIn('周一闭馆', result['city_hint']) + + def test_invalid_dates_days_and_overflow_fail_cleanly(self): + for start, days in [(date.today(), -1), (date.today(), 0), (date.today(), 367), (date.max, 2)]: + with self.assertRaises(ValueError): + calendar.analyze(start, days, None) + run = subprocess.run([sys.executable, str(ROOT / 'scripts/holiday_check.py'), '--from', '2026-02-30'], capture_output=True, text=True) + self.assertNotEqual(run.returncode, 0) + self.assertNotIn('Traceback', run.stderr) + + +class EnvironmentTests(unittest.TestCase): + def test_moving_root_updates_only_owned_block_and_quotes_special_paths(self): + with tempfile.TemporaryDirectory() as folder: + rc = Path(folder) / '.zshrc' + rc.write_text('# my settings\nexport OTHER=abc\nexport ATLAS_ROOT=/old/manual\n') + target = folder + '/root "quoted" $value `name`' + environment.update_environment(rc, '/first/root') + environment.update_environment(rc, target) + before = rc.read_text() + environment.update_environment(rc, target) + self.assertEqual(rc.read_text(), before) + self.assertEqual(before.count(environment.BEGIN), 1) + self.assertIn('export OTHER=abc', before) + result = subprocess.run(['bash', '-c', 'source "$1"; printf "%s" "$ATLAS_ROOT"', 'shell', str(rc)], capture_output=True, text=True) + self.assertEqual(result.stdout, target) + + def test_incomplete_managed_block_cannot_delete_user_settings(self): + with tempfile.TemporaryDirectory() as folder: + rc = Path(folder) / '.zshrc' + original = environment.BEGIN + "\n# user setting\nexport OTHER=kept\n" + rc.write_text(original) + with self.assertRaises(ValueError): + environment.update_environment(rc, '/new') + self.assertEqual(rc.read_text(), original) + + def test_legacy_block_is_migrated_and_symlink_preserved(self): + with tempfile.TemporaryDirectory() as folder: + real = Path(folder) / 'realrc' + link = Path(folder) / '.zshrc' + real.write_text('# AtlasAgent (https://github.com/wpfbcr/AtlasAgent)\nexport ATLAS_ROOT="/old"\n# other\n') + link.symlink_to(real) + environment.update_environment(link, '/new') + self.assertTrue(link.is_symlink()) + self.assertIn('# other', real.read_text()) + self.assertNotIn('/old', real.read_text()) + + +class DoctorTests(unittest.TestCase): + def test_required_failure_returns_nonzero_and_special_path_is_valid_json(self): + with tempfile.TemporaryDirectory() as folder: + env = os.environ.copy() + env['ATLAS_ROOT'] = folder + '/missing"root' + run = subprocess.run(['bash', str(ROOT / 'scripts/atlas-agent'), 'doctor', '--json', '--offline'], capture_output=True, text=True, env=env) + self.assertEqual(run.returncode, 1) + result = json.loads(run.stdout) + self.assertEqual(result['status'], 'unavailable') + self.assertFalse(result['scripts']) + self.assertEqual(result['atlas_root'], env['ATLAS_ROOT']) + + def test_all_nine_skills_and_optional_pdf_are_classified(self): + with tempfile.TemporaryDirectory() as folder, patch.object(doctor, 'pdf_probe', return_value={'ok': False, 'detail': 'optional dependency missing'}), patch.object(doctor, 'run_probe', return_value={'ok': True, 'detail': ''}): + dest = Path(folder) / '.codex/skills' + shutil.copytree(ROOT / 'skills', dest) + result, code = doctor.diagnose(ROOT, folder) + self.assertEqual(code, 0) + self.assertEqual(result['status'], 'degraded') + self.assertTrue(result['installed_skills']['codex']['ok']) + (dest / 'currency/SKILL.md').unlink() + result, code = doctor.diagnose(ROOT, folder) + self.assertEqual(result['installed_skills']['codex']['missing'], ['currency']) + + def test_online_failure_cannot_report_success(self): + with patch.object(doctor, 'pdf_probe', return_value={'ok': True, 'detail': ''}), patch.object(doctor, 'run_probe', return_value={'ok': False, 'detail': 'offline'}): + result, code = doctor.diagnose(ROOT, '/nonexistent') + self.assertEqual(code, 1) + self.assertEqual(result['status'], 'unavailable') + + +try: + import md2pdf + import pymupdf + PDF_AVAILABLE = True +except (ImportError, SystemExit): + PDF_AVAILABLE = False + + +@unittest.skipUnless(PDF_AVAILABLE, 'Optional PDF test dependencies require requirements-dev.txt') +class PDFTests(unittest.TestCase): + def setUp(self): + try: + self.font = md2pdf.find_font() + except ValueError as exc: + self.skipTest(str(exc)) + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.folder = Path(self.tmp.name) / 'notes.md.folder' + self.folder.mkdir() + + def export(self, text): + source = self.folder / 'input.md' + source.write_text(text, encoding='utf-8') + output = Path(md2pdf.convert_file(source, self.font)) + self.assertEqual(output, source.with_suffix('.pdf')) + reader = pymupdf.open(output) + self.addCleanup(reader.close) + text = '\n'.join(page.get_text() for page in reader) + self.assertNotIn('\x00', text, 'Extraction must preserve actual Unicode text') + return reader, text + + def test_continuous_lines_and_special_directory_preserve_content(self): + reader, text = self.export('第一行连续正文\n第二行连续正文\nThird line without blank\n') + self.assertIn('第二行连续正文', text) + self.assertIn('Third line without blank', text) + + def test_long_table_and_labels_are_not_truncated(self): + long = '上午熊猫基地,下午文殊院,晚间返回酒店。' * 12 + '尾部唯一标记' + reader, text = self.export('| 日期 | 完整行程 | 标签 |\n|---|---|---|\n| D1 | ' + long + ' | 🟢 🟡 🔴 ⚠️ ⭐⭐⭐ |\n') + self.assertIn('尾部唯一标记', text) + self.assertIn('[符合已知限制]', text) + self.assertIn('[需调整并确认]', text) + self.assertIn('★★★', ''.join(text.split())) + + def test_multipage_table_repeats_heading_and_preserves_last_row(self): + rows = ['| 日期标题 | 行程内容标题 |', '|---|---|'] + rows.extend('| D{} | {} |'.format(i, '内容完整保留。' * 10) for i in range(50)) + reader, text = self.export('\n'.join(rows)) + self.assertGreater(len(reader), 1) + self.assertIn('D49', text) + for page in reader: + self.assertIn('日期标题', page.get_text()) + + def test_table_escaped_pipe_and_empty_first_heading(self): + reader, text = self.export('| | 内容 |\n|---|---|\n| D1 | 左\\|右 |\n') + self.assertIn('左|右', text) + self.assertIn('D1', text) + + +if __name__ == '__main__': + unittest.main()