Skill: qingheyang/easyaccounts (version 0.2.0)

--- name: easyaccounts description: 家庭财务管家 / Family finance manager. 通过自然语言对接 EasyAccounts 个人记账系统,支持记账、查账、批量记账、内部转账、流水修改、收支统计、年度分析、Excel 导出、系统公告查询等。Manage household accounts, expenses, income, transfers, statistics and reports for EasyAccounts (a self-hosted personal finance/bookkeeping system with MySQL backend). homepage: https://github.com/EasyAccounts metadata: openclaw: requires: bins: [curl, jq] env: [EASYACCOUNTS_URL] primaryEnv: EASYACCOUNTS_URL install: - id: brew kind: brew formula: jq bins: [jq] label: 安装 jq (brew) - id: apt kind: apt package: jq bins: [jq] label: 安装 jq (apt) --- # EasyAccounts 家庭财务管家 你是一个家庭小财务官,负责管理 EasyAccounts 个人记账系统。本 skill 提供完整的记账能力:查询账户、记录收支、生成报表。 ## 何时使用 - 用户要求**记账、查账、统计收支**(如"记一笔午餐 30 块"、"查上个月支出"、"导出 1 月账单") - 用户提到**账户余额、分类消费、年度总结** - 涉及 EasyAccounts 系统的任何操作 ## 何时不使用 - 用户只是闲聊财务话题,没有具体操作请求 - 涉及股票、基金、加密货币等投资类查询(本系统不支持) ## 不支持的操作(明确告知用户) - **删除流水**:本 skill **不提供**删除功能(高风险不可逆操作)。用户要求删除时,引导用户**到前端 EasyAccounts 网页/桌面端手动删除**(前端删除会同步恢复账户余额) - **删除账户、分类、动作**:同上,只读不删 - **创建账户、分类、动作**:本 skill 不涉及主数据维护,引导用户去前端 --- ## ⚠️⚠️⚠️ LLM 强制行为规范(必读,违反必失败) ### 规则 1:**必须使用本 skill 提供的脚本,严禁自己拼 curl** 本 skill 在 `scripts/` 下提供了所有需要的脚本,**全部经过端到端测试**。它们处理了: - Windows + Git Bash 的 UTF-8/GBK 编码坑(直接用 `curl -d` 会乱码) - token 自动加载和续期 - 必填字段校验、错误码解析、来源标记追加 - 401 自动重试 + env 凭据自动登录 **你绝对不要**: - ❌ 凭"常识"猜 API 路径(如 `/user/login`、`/api/v1/auth`) - ❌ 自己手写 `curl -X POST -d '{...}'` 来登录或写入数据 - ❌ 读脚本源码学接口然后绕开脚本 - ❌ 在登录前先尝试"探测"接口 - ❌ 因为脚本失败就以为脚本"有问题",改用裸 curl **你必须**: - ✅ 用户给账号密码 → **立刻** `bash {baseDir}/scripts/login.sh <username> <password>`,不做任何"先试试" - ✅ 用户给 URL → 看下面【规则 2】检查是否需要补 `/api` - ✅ 写流水 → 用 add_flow.sh / batch_add_flow.sh / update_flow.sh - ✅ 查流水 → 用 query_flows.sh - ✅ 简单 GET(accounts/types/actions/get_flow/year_statistics)→ 用文档示例的 curl + token 文件读取(不是凭空构造) **唯一允许自己写 curl 的场景**:操作清单里**明确给了 curl 示例**的简单 GET(accounts/types/actions/get_flow/year_statistics)。**写入/登录类操作绝对不允许**。 ### 规则 2:URL 配置陷阱(LLM 必读,90% 失败的原因) 用户给你的 URL 通常是**前端浏览器地址**,不是 API 地址。EasyAccounts 标准部署用 nginx 代理,API 实际在 `<前端URL>/api/` 路径下。 | 用户给的 | 应设置的 `EASYACCOUNTS_URL` | |---------|----------------------------| | `http://example.com:8080/` | `http://example.com:8080/api` ✅ | | `https://easy.example.com/` | `https://easy.example.com/api` ✅ | | `http://localhost:8081`(直连后端,无 nginx) | `http://localhost:8081` ✅ | **故障特征 → 立即怀疑 URL**: - 遇到 **HTTP 405 Method Not Allowed** → **99% 是 URL 漏了 `/api`**(POST 打到了 nginx 的静态 SPA fallback) - 响应是 HTML(`<!doctype html>`...)而不是 JSON → 同上 - 任何类似"路径不存在/方法不对"的网络层错误 → **第一反应:URL 是不是漏了 /api** **修复**:`export EASYACCOUNTS_URL=<前端URL>/api`,然后重试。 ### 规则 3:遇到 401 的处理流程 ``` HTTP 401 ↓ env 已设 EASYACCOUNTS_USERNAME / EASYACCOUNTS_PASSWORD ? ↓ 是 → 脚本会自动登录重试,LLM 啥都不用做(这里看到 401 说明自动登录也失败了) ↓ 否 → 向用户索要账号密码 → 调 login.sh <username> <password> → 重试原操作 ``` **禁止**:看到 401 就开始猜路径、改字段名、绕过脚本。这都是无用功。 --- --- ## 准备工作 ### 环境变量(用户配置在 `~/.openclaw/.env`) | 变量 | 必需 | 说明 | |------|------|------| | `EASYACCOUNTS_URL` | ✅ | **纯净的 baseurl**,格式 `http(s)://{host}[:{port}]`,**不能带末尾斜杠或路径** | | `EASYACCOUNTS_USERNAME` | ❌ | 仅服务端开启登录时需要 | | `EASYACCOUNTS_PASSWORD` | ❌ | 同上,脚本自动 MD5 | **`EASYACCOUNTS_URL` 严格格式(LLM 必读)**: - ✅ 合法:`http://192.168.30.201:10669`、`http://www.lllama.cn:18505`、`https://easy.example.com` - ❌ 不合法: - `http://192.168.30.201:10669/`(末尾斜杠) - `http://192.168.30.201:10669/api`(带路径) - `http://192.168.30.201:10669/anything`(任何路径都不行) - `192.168.30.201:10669`(缺协议) **LLM 接收用户 URL 时**:用户复制的就是浏览器地址栏 `http://host:port`,**直接原样写入** env,**不要追加任何路径**。如果用户给的 URL 末尾带 `/`,**应该 strip 掉**再写入。 **`/api` 是脚本内部固定追加的**,因为 EasyAccounts 是单端口部署,所有 API 都走 nginx 的 `/api/` 代理,这是部署架构决定的,不需要用户感知。 ### 认证(LLM 通常无需关心) | 场景 | 行为 | |------|------| | 未开启登录 | 直接调用,token 为空也通过 | | 开启登录 + env 有凭据 | 401 自动登录、缓存 token、无感重试 | | 开启登录 + env 无凭据 | 操作返回 `认证失败(HTTP 401)`,LLM **必须**调 `login.sh <username> <password>` | | **用户主动给了凭据**(如"用户名 admin 密码 xxx") | **立刻**调 `login.sh admin xxx`,不要先"试试"其他东西 | **关键原则**: - **永远用 login.sh,不要自己拼 curl 调 /auth/login**(参见上方【规则 1】) - **不要硬编码密码到代码或参数中**,只在用户提供时使用 - **不要在用户已经给了凭据的情况下还去探测接口**,直接登录就好 --- ## ⚠️ 核心业务规则(极其重要) ### typeId 和 actionId 的区别(不可混淆) 这是本系统最容易出错的地方,请务必理解: - **typeId**:记账分类(科目),如"餐饮"、"交通"。从 `types` 接口的 `id` 字段获取。 - **actionId**:收支动作(借/贷),决定这笔账是收入还是支出。获取方式有两种: 1. 分类已绑定 action → `types` 返回数据中 `actionId` 字段不为 null,直接用 2. 分类未绑定 action → `actionId` 为 null,**必须**调用 `actions` 接口获取 **typeId 是"花在什么上",actionId 是"收还是支",两者完全不同!** ### 收支类型(handle)取值 - `0` = 收入 - `1` = 支出 - `2` = 内部转账 - `3` = 全部(仅查询时使用) `handle` 不是 `actionId`,只是收支方向标识。 ### 分类可用性规则 每个分类标注"可用"或"不可用": - 有子分类的一级分类 → **不可用**(必须选其子分类) - 无子分类的一级分类 → 可用 - 所有二级分类 → 可用 只能使用标注为"可用"的分类。 ### 金额规则 - **只传正数**,不要带负号 - 系统根据 actionId 的收支类型自动处理正负 - 格式如 `"100.00"`,保留 2 位小数 --- ## ⚠️ JSON 字段名(后端 Lombok+Jackson 序列化坑) **同一概念在不同接口里有 3 套命名**,根源是后端有的接口返回 DTO(用了 `name`),有的接口返回 entity(用 `aName` → 序列化为 `aname`),还有的用了自定义字段名(`accountName`)。LLM 必须按接口选字段名,**不能跨接口套用**: ### 速查表 | 概念 | `/account/getAccount` (DTO) | flows 列表 / get_flow 嵌套 / types (entity) | year_statistics 嵌套 | |------|------------------------------|---------------------------------------------|----------------------| | 账户名 | `name` | `aname`(小写) | `accountName` | | 分类名 | — | `tname`(小写) | — | | 动作名 | — | `hname`(小写) | — | | 日期 | — | `fdate`(小写) | — | | 转入账户名 | — | `toAName`(驼峰,转账时有值) | — | **特别注意**:`get_flow` 接口里的嵌套 `account` 对象**走 entity 路径**,字段是 `aname` 不是 `name`!跟 `/account/getAccount` 不一样。 **记忆法**:Java 字段 `aName` → JSON `aname`(小写),`name` → JSON `name`(原样),`accountName` → JSON `accountName`(原样)。 ### accounts 接口(`/account/getAccount`) | 字段 | 类型 | 说明 | |------|------|------| | `id` | int | 账户 ID | | `name` | string | 账户名 | | `money` | string | 余额 | | `exemptMoney` | string | 免计金额 | | `accountType` | int | 0=资产,1=负债 | | `note` | string | 备注 | | `card` | string | 卡号 | ### flows 列表接口(`/screen/getFlowByScreen`) 返回**扁平字段**: | 字段 | 类型 | 说明 | |------|------|------| | `id` | int | 流水 ID | | `aname` | string | 账户名 | | `tname` | string | 分类名 | | `hname` | string | 收支动作名("支出"/"收入"等) | | `handle` | int | 0=收入 1=支出 2=转账 | | `money` | string | 金额 | | `fdate` | string | 流水日期 | | `note` | string | 备注 | | `toAName` | string | 转入账户名(转账时有) | | `from` | string | 来源标记 | | `collect` | bool | 是否收藏 | | `hasImages` | bool | 是否有图片 | | `exempt` | bool | 是否免计 | ### get_flow 详情接口(`/flow/getFlow/{id}`) 返回**嵌套对象**(注意!跟列表接口结构完全不一样,也跟 `/account/getAccount` 字段名不一样): | 字段 | 类型 | 说明 | |------|------|------| | `id` / `money` / `fdate` / `note` / `from` / `collect` | 同列表 | 扁平字段 | | `account` | object | 源账户(entity 序列化):`{id, aname, money(余额), exemptMoney, card, accountType, note}` ⚠️ **是 `aname` 不是 `name`** | | `accountTo` | object | 转入账户(转账时有),字段同 account | | `type` | object | `{id, tname, parent, action(嵌套), childrenTypes, hasChild, ...}` | | `action` | object | `{id, handle, hname, exempt, exemptMode, disable}` | | `images` | array | 图片 URL 列表(可能为 null) | ### types 接口(`/type/getType`) | 字段 | 说明 | |------|------| | `id` | 分类 ID(**这就是 typeId**) | | `tname` | 分类名 | | `action` | 嵌套对象 `{id, handle, hname}` 或 **null**(分类未绑定 action) | | `childrenTypes` | 子分类列表(有则一级分类不可用,需选子) | --- ## 工具调用流程 ### 流程 A:查询流水 ``` 1. 调用 current_date 获取当前日期(如涉及时间) 2. 如需按分类筛选 → 调用 types 获取 typeId 3. 如需按账户筛选 → 调用 accounts 获取 accountId 4. 调用 flows 执行查询 ``` ### 流程 B:添加流水(最重要) ``` 1. 调用 current_date 获取日期(用户未指定时) 2. 调用 accounts 获取 accountId 3. 调用 types 获取 typeId,同时检查该分类的 actionId 4. 如果 actionId != null → 直接使用 如果 actionId == null → 调用 actions,根据用户的收支意图(handle)选择对应的 action 5. 单笔 → 调用 add_flow 多笔(2 条及以上) → 调用 batch_add_flow,把所有条目组装成 JSON 数组一次性提交 ``` **重要细节**: - **金额规范化**:用户的金额可能带"块"、"元"、"块钱"等单位或符号,LLM 应解析成纯数字。如"30 块" → `30.00`,"一百二" → `120.00` - **createDate 不需要传**:helper 脚本自动设置当前时间 - **内部转账(转账给自己的另一个账户)请走【流程 G】**,需要 `accountToId` - **金额只传正数**:即便用户说"花了 30",也是传 `30` 不是 `-30`,系统根据 actionId 自动定方向 ### 流程 C:更新流水 ``` 1. 调用 flows 查询获取 flowId 2. 如需修改分类 → 调用 types,检查 actionId 3. 如果 actionId == null → 调用 actions 4. 调用 update_flow 更新 ``` ### 流程 D:导出 Excel(基于查询) ``` 1. 总是先调用 query_flows 查询(看一眼数量和数据) 2. 检查返回字段: - is_truncated == false 且数量较少 → 直接展示给用户,不需要导出 - is_truncated == true(>100 条) → 主动告知"共 X 条,只展示了前 100 条,可导出 Excel" 3. 用户确认导出 → 用相同的查询参数 + --export "<文件名>" 调 query_flows 4. 文件生成在服务器 Resource/excel/screen/ 目录(脚本返回 fileName) 5. **没有直接的 downloadUrl**,告诉用户:"文件已生成在服务器,请到前端下载页面或让运维拷贝" ``` ### 流程 E:查询系统信息和公告 ``` 1. 用户问"有什么公告/通知" → 调用 system_info notices 2. 用户问"系统版本/有没有更新/我的配置" → 调用 system_info version 3. 用户首次使用或问"系统状态" → 调用 system_info all ``` ⚠️ notices 返回的公告中可能含**已过期**的(`expire` 字段不为 null 且早于今天),LLM 应**主动跳过**,不要展示给用户。 ### 流程 F:删除流水(本 skill 不支持) ``` 用户要求删除时: 1. 不要调用任何 API 2. 直接告知:"出于安全考虑,本 skill 不支持删除流水。请到前端 EasyAccounts 网页/桌面端手动删除,前端会自动恢复账户余额。" 3. 如果用户只是想"撤销错误的记账",建议改用 update_flow 修改金额或备注 ``` ### 流程 G:内部转账(handle=2,与普通收支不同) ``` 用户场景:"从微信转 500 到银行卡"、"工资转入理财账户" 1. 调用 accounts 拿到源账户和目标账户的 ID 2. 调用 actions,找 handle == 2 的动作(通常名为"内部转账") 3. typeId 选用一个转账类目(通常是某个被标记为 handle=2 的分类), 或如果没有专属转账类目,可以复用普通分类(具体看你的 EasyAccounts 数据) 4. 调用 add_flow,**必须**传 `--account-to-id <目标账户ID>` 5. 金额仍然只传正数,后端自动处理"源账户 -money,目标账户 +money" ``` --- ## 操作清单 所有 GET 类操作直接用 curl 调用,需要带 token header。token 从 `~/.config/easyaccounts/token` 读取。 ### login(登录) ```bash bash {baseDir}/scripts/login.sh <username> <password> ``` ### current_date(获取当前日期) 不需要 API 调用,直接用 `date` 命令: ```bash date '+%Y-%m-%d' ``` ### accounts(查询账户列表) ```bash TOKEN=$(cat ~/.config/easyaccounts/token) curl -s -H "authorization: $TOKEN" "$EASYACCOUNTS_URL/api/account/getAccount" | jq '.' ``` 返回所有账户的 id、名称、余额。 ### types(获取分类列表) ```bash TOKEN=$(cat ~/.config/easyaccounts/token) curl -s -H "authorization: $TOKEN" "$EASYACCOUNTS_URL/api/type/getType" | jq '.' ``` 返回分类层级结构。**重点检查每个分类的 `action.id`**: - 不为 null → 这就是 actionId,直接用 - 为 null → 需要调用 actions 接口 ### actions(获取收支动作列表) ```bash TOKEN=$(cat ~/.config/easyaccounts/token) curl -s -H "authorization: $TOKEN" "$EASYACCOUNTS_URL/api/action/getAction" \ | jq '.data[] | select(.disable != true) | {id, hname, handle}' ``` 只在 types 返回的 actionId 为 null 时调用。根据 handle(0=收入,1=支出,2=转账)选择对应的 action.id 作为 actionId。 ### year_statistics(年度统计) ```bash TOKEN=$(cat ~/.config/easyaccounts/token) YEAR=2026 curl -s -H "authorization: $TOKEN" "$EASYACCOUNTS_URL/api/home/getHomeInfoV2/$YEAR" | jq '.data' ``` **返回字段**: | 字段 | 说明 | |------|------| | `totalAsset` | 总资产 | | `netAsset` | 净资产(总资产 - 负债) | | `yearIncome` | 该年总收入 | | `yearOutCome` | 该年总支出 | | `yearBalance` | 该年盈余(收入 - 支出) | | `curIncome` | 当月收入(只在查询当年时有值,跨年查询为 null) | | `curOutCome` | 当月支出(同上) | | `accounts` | 数组,各账户资产明细 | | `monthDetails` | 数组,各月详情 | **`accounts` 子字段**(注意!**第三种账户名命名**): | 子字段 | 说明 | |--------|------| | `id` | 账户 ID | | `accountName` | 账户名(**注意:不是 `name` 也不是 `aname`**) | | `accountAsset` | 账户资产值 | | `exemptAsset` | 免计资产 | | `percent` | 占总资产百分比 | | `accountType` | 0=资产,1=负债 | | `note` | 备注 | ### system_info(系统信息和公告) 合并了 `/home/getNotices` 和 `/home/getVersion` 两个接口,通过参数选择内容: ```bash # 获取项目公告(用户问"有什么公告/通知/动态") bash {baseDir}/scripts/system_info.sh notices # 获取版本和系统配置(用户问"系统版本/有没有新版/我的登录配置") bash {baseDir}/scripts/system_info.sh version # 同时获取两者(默认,用户首次使用或问"系统状态") bash {baseDir}/scripts/system_info.sh all ``` **notices 返回字段**(数组): - `id` / `title` / `content` / `date` - `url` — 相关链接(可能为 null) - `expire` — 过期时间(可能为 null,null 表示永不过期) ⚠️ **LLM 必须主动过滤过期公告**:`expire` 不为 null 且早于今天的公告**不要展示**。如不确定今天日期,先调用 current_date(`date '+%Y-%m-%d'`)。 **version 返回字段**(对象): - `versions` — 各模块版本号:`fontBranch`(前端)、`backendBranch`(后端)、`mysqlBranch`、`agentBranch`、`webhookBranch`、`release`(总版本)、`versionCode` - `auth` — 登录配置:`enable`、`expiredMinutes`(token 过期分钟数)、`singleLogin`(是否单点登录) - `backup` — 数据库备份配置:`cron`、`valid`、`description` - `update` — 云端更新检查: - 如果无更新:`{hasUpdate: false, message: "当前已是最新版本"}` - 如果有更新:`{hasUpdate: true, newVersion, newVersionCode, releaseDate, changelog, currentVersionCode}`,应主动告知用户可以升级 **使用场景**: - 用户问"我用的是几版本" → version,回答 `release` 字段 - 用户问"有没有新版" → version,看 `update.hasUpdate` - 用户问"项目有什么公告" → notices,把 title+content 列出来,有 url 的附上 - 用户问"系统怎么样/有什么消息" → all ### flows(查询流水) 参数复杂,使用 helper 脚本: ```bash bash {baseDir}/scripts/query_flows.sh \ --handle 1 \ --start-date 2026-03-01 \ --end-date 2026-03-31 \ [--account-id 1] \ [--types 5,8] \ [--note 餐饮] \ [--single-month true] \ [--analysis true] \ [--order-by 2] ``` **参数说明**: | 参数 | 必填 | 说明 | |------|------|------| | `--handle` | ✅ | 0=收入 1=支出 2=转账 3=全部 | | `--start-date` | ❌ | 开始日期 yyyy-MM-dd | | `--end-date` | ❌ | 结束日期 yyyy-MM-dd | | `--single-month true` | ❌ | 单月查询模式,只需 start-date(取该月任意日期),省略 end-date | | `--account-id N` | ❌ | 按账户筛选,先用 accounts 拿 ID | | `--types 5,8` | ❌ | **分类 ID 列表**(逗号分隔),不是分类名!需先调 types 拿 ID | | `--note 餐饮` | ❌ | **单个**关键字,模糊匹配备注(不支持多关键字 AND/OR) | | `--analysis true` | ❌ | 返回每条流水占总收入/支出的百分比 | | `--order-by N` | ❌ | 0=金额升序 1=金额降序 2=时间升序;**默认按时间倒序(最新在前)** | **返回字段**: | 字段 | 说明 | |------|------| | `summary` | 汇总字符串"收入=X,支出=Y,盈余=Z" | | `flows` | 格式化的流水字符串数组(每条形如 `流水ID:N;收支:Y;金额:Z;...`) | | `total_count` | 符合条件的总数(截断前) | | `returned_count` | 实际返回数量 | | `is_truncated` | bool,**true 表示超过 100 条被截断** | | `notice` | 截断时的提示消息 | **截断规则**:超过 100 条时,**保留按当前排序的前 100 条**(默认时间倒序即最新的)。剩余数据**无法**通过 flows 获取,**必须**用 make_excel 导出完整数据。LLM 应主动提醒用户。 ### get_flow(获取单条流水详情) ```bash TOKEN=$(cat ~/.config/easyaccounts/token) FLOW_ID=123 curl -s -H "authorization: $TOKEN" "$EASYACCOUNTS_URL/api/flow/getFlow/$FLOW_ID" | jq '.' ``` ### add_flow(添加流水) 使用 helper 脚本: ```bash bash {baseDir}/scripts/add_flow.sh \ --account-id 1 \ --type-id 5 \ --action-id 2 \ --money 30.00 \ --date 2026-04-01 \ --note "公司午餐" \ [--account-to-id 2] \ [--collect false] ``` **参数说明**: - `--money`:**只传正数**,系统根据 actionId 的 handle 自动处理正负 - `--account-to-id`:**仅** handle=2(内部转账)时传,其他情况不要传 - `--note`:用户的备注。脚本内部会自动追加来源标识,LLM 不需要管 - `--collect`:可选,默认 false - **不需要传 createDate**,脚本自动设置当前时间 ### batch_add_flow(批量添加流水) 当用户一次提交多笔流水(如"记一下今天的:午餐30、地铁12、咖啡25"),用此脚本批量处理,**避免循环调用 add_flow.sh**(慢且无汇总)。 输入是 JSON 数组(通过文件或 stdin): ```bash # 方式一:通过文件 bash {baseDir}/scripts/batch_add_flow.sh /tmp/flows.json # 方式二:通过 stdin(推荐,无需临时文件) cat <<'EOF' | bash {baseDir}/scripts/batch_add_flow.sh - [ { "accountId": 1, "typeId": 5, "actionId": 2, "money": "30.00", "fDate": "2026-04-01", "note": "公司午餐" }, { "accountId": 1, "typeId": 8, "actionId": 2, "money": "12.00", "fDate": "2026-04-01", "note": "地铁" }, { "accountId": 1, "typeId": 9, "actionId": 2, "money": "25.00", "fDate": "2026-04-01", "note": "咖啡" } ] EOF ``` **输入字段**(同 add_flow): - 必填:`accountId`、`typeId`、`actionId`、`money`、`fDate` - 可选:`note`、`accountToId`(转账)、`collect` **特性**: - 单条失败不中断,继续处理后续条目 - 进度输出到 stderr(`✅`/`❌`),最终汇总 JSON 输出到 stdout - 返回结构包含 `successCount`、`failedCount`、`successList`(含每条 flowId)、`failedList`(含失败原因) **使用前提**: - 多笔流水通常涉及不同分类,**先调用 types** 获取 typeId 和 actionId - 如果不同笔涉及不同账户,先调用 accounts - 同一批次的 createDate 相同,但 fDate 可不同 ### update_flow(更新流水) 使用 helper 脚本: ```bash bash {baseDir}/scripts/update_flow.sh \ --flow-id 123 \ --account-id 1 \ --type-id 5 \ --action-id 2 \ --money 35.00 \ --date 2026-04-01 \ --note "公司午餐(改正)" \ [--account-to-id 2] \ [--collect false] ``` ### make_excel(导出 Excel) 参数与 flows 类似,复用 query_flows.sh 的参数解析,但调用 `--export <文件名>`: ```bash bash {baseDir}/scripts/query_flows.sh \ --export "2026年3月账单" \ --handle 1 \ --start-date 2026-03-01 \ --end-date 2026-03-31 ``` **返回字段**: - `success`:bool - `fileName`:生成的实际文件名(含时间戳后缀) - `notice`:提示文本 ⚠️ **没有 downloadUrl 字段**。文件生成在服务器 `Resource/excel/screen/` 目录,需要用户: - 通过 EasyAccounts 前端的下载页面获取 - 或联系运维拷贝 LLM 应该把 `fileName` + `notice` 一起告诉用户,**不要承诺给链接**。 --- ## 回复规范 - 金额保留 2 位小数 - 流水列表用简洁格式(ID/收支/金额/账户/分类/时间) - 统计结果突出**收入、支出、盈余**三个数字 - 流水超 100 条时主动提示可用 make_excel 导出 - 涉及日期的操作前先调用 `date` 命令获取当前日期作为参考 - 写操作完成后,简短确认结果(包含 flowId,方便用户后续修改) ## 错误处理 - **HTTP 401**:服务端开启了登录但未提供 token,或 token 已过期(默认 30 分钟)。引导用户提供账号密码后调 login.sh,然后重试 - **业务错误 code != 0**:看 msg 字段,通常是参数错误或业务规则限制 - **缺少必要参数**:不要瞎猜,先调用对应的查询接口(accounts/types/actions/flows)拿到准确 ID - **分类不可用**:LLM 选了有子分类的一级分类,后端会拒绝。重新让用户从子分类里选 - **删除请求**:本 skill 不支持,引导前端处理(见【流程 F】) - **批量操作部分失败**:batch_add_flow 返回的 `failedList` 含失败索引和原因,LLM 应总结哪些条成功、哪些失败,失败的让用户决定是否手动重试

path: README.md
content: # EasyAccounts Skill for OpenClaw

让你的 OpenClaw AI 助手成为家庭财务管家 — 通过自然语言记账、查账、统计和导出报表。

> 对接 [EasyAccounts](https://github.com/QingHeYang/EasyAccounts) 个人记账系统。

---

## 它能做什么

- **🦞 记一笔流水** — "记一下午餐 30 块"
- **🦞 批量记账** — "今天的:午餐 30、地铁 12、咖啡 25"
- **🦞 查询流水** — "查上个月的餐饮支出"、"今年最大的 5 笔开销"
- **🦞 修改流水** — "把刚才那笔午餐改成 35"
- **🦞 内部转账** — "从微信转 500 到银行卡"
- **🦞 年度统计** — "今年总收入多少"、"现在总资产多少"
- **🦞 导出 Excel** — "把 3 月的账单导出"
- **🦞 系统信息** — "有什么新公告"、"我用的什么版本"

所有由本 skill 创建的流水会标记 `from=Claw` 和备注末尾追加 `#Claw记账`,方便你区分。

---

## 安装

```bash
clawhub install easyaccounts
# 或
openclaw skills install easyaccounts
```

## 配置

在 `~/.openclaw/.env` 中设置:

```bash
# 必需
EASYACCOUNTS_URL=http://your-easyaccounts-server:8081
# 或带 nginx 代理路径
# EASYACCOUNTS_URL=http://example.com/api

# 可选(仅服务端开启了登录时需要)
EASYACCOUNTS_USERNAME=admin
EASYACCOUNTS_PASSWORD=yourpassword
```

> 安全建议:`chmod 600 ~/.openclaw/.env`

## 依赖

- `curl`(几乎所有系统都自带)
- `jq` — JSON 处理
  - macOS: `brew install jq`
  - Ubuntu/Debian/WSL: `apt install jq`
  - Windows: 走 WSL2,或下载 [jq for Windows](https://jqlang.github.io/jq/download/)

OpenClaw 在 Windows 推荐使用 WSL2,本 skill 在 WSL2 内运行无任何额外配置。

---

## 使用示例

配置好后,直接对你的 OpenClaw 代理说话即可:

> **你**:今天午餐花了 35,记一下
> **AI**:已记录,流水 ID #4828:现金 -35.00,分类:餐饮,日期:2026-04-07

> **你**:查一下这个月吃饭花了多少
> **AI**:本月餐饮支出共 8 笔,合计 ¥523.50,占总支出 31%。最大一笔是…

> **你**:今天的:午餐 30、地铁 12、咖啡 25,记一下
> **AI**:已批量记 3 笔,总计 ¥67.00 …

---

## 不支持的操作

出于**安全考虑**,本 skill **不提供以下功能**:

- ❌ 删除流水(需到 EasyAccounts 前端手动删除)
- ❌ 删除/创建账户、分类、动作(主数据维护请用前端)

---

## 配套前端识别

本 skill 写入的流水带 `from=Claw` 标记。如果你的 EasyAccounts 前端是 v2.6.1+,会显示一个**粉色 `Claw` 标签**;旧版本只显示备注里的 `#Claw记账` 文字。

---

## 项目地址

- **本 Skill**:[GitHub](https://github.com/QingHeYang/EasyAccounts/tree/main/skills/easyaccounts)
- **EasyAccounts 主项目**:[GitHub](https://github.com/QingHeYang/EasyAccounts)
- **问题反馈**:[Issues](https://github.com/QingHeYang/EasyAccounts/issues)

## License

MIT-0(MIT No Attribution),允许任何使用包括商用,无附加条款。


path: scripts/add_flow.sh
content: #!/usr/bin/env bash
# 添加流水(自动追加 Claw记账 标记和 from=Claw)
#
# 用法:
#   add_flow.sh \
#     --account-id 1 \
#     --type-id 5 \
#     --action-id 2 \
#     --money 30.00 \
#     --date 2026-04-01 \
#     --note "公司午餐" \
#     [--account-to-id 2] \
#     [--collect false]

set -euo pipefail
source "$(dirname "$0")/_common.sh"

ea_check_deps
ea_check_env

# 默认参数
ACCOUNT_ID=""
TYPE_ID=""
ACTION_ID=""
MONEY=""
F_DATE=""
NOTE=""
ACCOUNT_TO_ID=""
COLLECT="false"

# 参数解析
while [[ $# -gt 0 ]]; do
  case "$1" in
    --account-id)    ACCOUNT_ID="$2"; shift 2 ;;
    --type-id)       TYPE_ID="$2"; shift 2 ;;
    --action-id)     ACTION_ID="$2"; shift 2 ;;
    --money)         MONEY="$2"; shift 2 ;;
    --date)          F_DATE="$2"; shift 2 ;;
    --note)          NOTE="$2"; shift 2 ;;
    --account-to-id) ACCOUNT_TO_ID="$2"; shift 2 ;;
    --collect)       COLLECT="$2"; shift 2 ;;
    *) ea_die "未知参数: $1" ;;
  esac
done

# 必填校验
[[ -n "$ACCOUNT_ID" ]] || ea_die "缺少 --account-id"
[[ -n "$TYPE_ID"    ]] || ea_die "缺少 --type-id"
[[ -n "$ACTION_ID"  ]] || ea_die "缺少 --action-id"
[[ -n "$MONEY"      ]] || ea_die "缺少 --money"
[[ -n "$F_DATE"     ]] || ea_die "缺少 --date"

# 业务处理
MONEY=$(ea_normalize_money "$MONEY")
NOTE=$(ea_append_note_tag "$NOTE" "$EA_NOTE_TAG_ADD")
CREATE_DATE=$(ea_now)

# 构造 JSON(用 jq 保证类型正确和转义)
BODY=$(jq -n \
  --argjson accountId "$ACCOUNT_ID" \
  --argjson typeId "$TYPE_ID" \
  --argjson actionId "$ACTION_ID" \
  --arg money "$MONEY" \
  --arg fDate "$F_DATE" \
  --arg note "$NOTE" \
  --argjson collect "$COLLECT" \
  --arg createDate "$CREATE_DATE" \
  --arg from "$EA_FROM_TAG" \
  '{
    accountId: $accountId,
    typeId: $typeId,
    actionId: $actionId,
    money: $money,
    fDate: $fDate,
    note: $note,
    collect: $collect,
    createDate: $createDate,
    from: $from
  }')

# 内部转账时追加 accountToId
if [[ -n "$ACCOUNT_TO_ID" ]]; then
  BODY=$(echo "$BODY" | jq --argjson aid "$ACCOUNT_TO_ID" '. + {accountToId: $aid}')
fi

# 调用 API
RESPONSE=$(ea_post "/flow/addFlow" "$BODY")

# 提取 flowId 并友好输出
FLOW_ID=$(echo "$RESPONSE" | jq -r '.data.id // empty')

jq -n \
  --arg fid "$FLOW_ID" \
  '{success: true, message: "流水添加成功", flowId: ($fid | tonumber? // null)}'


path: scripts/batch_add_flow.sh
content: #!/usr/bin/env bash
# 批量添加流水(自动追加 Claw记账 标记和 from=Claw)
#
# 用法:
#   batch_add_flow.sh <json_file>      # 从文件读取
#   batch_add_flow.sh -                # 从 stdin 读取
#
# 输入格式(JSON 数组):
# [
#   {
#     "accountId": 1,        # 必填
#     "typeId": 5,           # 必填
#     "actionId": 2,         # 必填
#     "money": "30.00",      # 必填(只传正数)
#     "fDate": "2026-04-01", # 必填
#     "note": "公司午餐",     # 可选
#     "accountToId": 2,      # 可选(内部转账时必填)
#     "collect": false       # 可选,默认 false
#   },
#   ...
# ]
#
# 输出:每条结果 + 汇总(成功/失败计数、失败明细)
# 行为:单条失败不中断,继续处理下一条

set -uo pipefail
source "$(dirname "$0")/_common.sh"

ea_check_deps
ea_check_env

if [[ $# -lt 1 ]]; then
  ea_die "用法: $0 <json_file>  或  $0 -"
fi

# 读取输入
if [[ "$1" == "-" ]]; then
  INPUT=$(cat)
else
  [[ -f "$1" ]] || ea_die "文件不存在: $1"
  INPUT=$(cat "$1")
fi

# 校验是 JSON 数组
if ! echo "$INPUT" | jq -e 'type == "array"' >/dev/null 2>&1; then
  ea_die "输入必须是 JSON 数组"
fi

TOTAL=$(echo "$INPUT" | jq 'length')
if (( TOTAL == 0 )); then
  ea_die "输入数组为空"
fi

echo "📋 共 $TOTAL 条待记账..." >&2

# 结果收集
SUCCESS_LIST="[]"
FAILED_LIST="[]"
SUCCESS_COUNT=0
FAILED_COUNT=0
CREATE_DATE=$(ea_now)

# 逐条处理
for (( i = 0; i < TOTAL; i++ )); do
  ITEM=$(echo "$INPUT" | jq ".[$i]")

  # 提取字段
  ACCOUNT_ID=$(echo "$ITEM" | jq -r '.accountId // empty')
  TYPE_ID=$(echo "$ITEM" | jq -r '.typeId // empty')
  ACTION_ID=$(echo "$ITEM" | jq -r '.actionId // empty')
  MONEY=$(echo "$ITEM" | jq -r '.money // empty')
  F_DATE=$(echo "$ITEM" | jq -r '.fDate // empty')
  NOTE=$(echo "$ITEM" | jq -r '.note // ""')
  ACCOUNT_TO_ID=$(echo "$ITEM" | jq -r '.accountToId // empty')
  COLLECT=$(echo "$ITEM" | jq -r '.collect // false')

  # 必填校验
  MISSING=""
  [[ -z "$ACCOUNT_ID" ]] && MISSING="${MISSING}accountId,"
  [[ -z "$TYPE_ID"    ]] && MISSING="${MISSING}typeId,"
  [[ -z "$ACTION_ID"  ]] && MISSING="${MISSING}actionId,"
  [[ -z "$MONEY"      ]] && MISSING="${MISSING}money,"
  [[ -z "$F_DATE"     ]] && MISSING="${MISSING}fDate,"

  if [[ -n "$MISSING" ]]; then
    REASON="第$((i+1))条缺少必填字段: ${MISSING%,}"
    echo "❌ $REASON" >&2
    FAILED_LIST=$(echo "$FAILED_LIST" | jq \
      --argjson idx "$i" --arg reason "$REASON" \
      '. + [{index: $idx, reason: $reason}]')
    FAILED_COUNT=$((FAILED_COUNT + 1))
    continue
  fi

  # 业务处理:去金额负号、追加标记
  MONEY=$(ea_normalize_money "$MONEY")
  NOTE=$(ea_append_note_tag "$NOTE" "$EA_NOTE_TAG_ADD")

  # 构造 payload
  BODY=$(jq -n \
    --argjson accountId "$ACCOUNT_ID" \
    --argjson typeId "$TYPE_ID" \
    --argjson actionId "$ACTION_ID" \
    --arg money "$MONEY" \
    --arg fDate "$F_DATE" \
    --arg note "$NOTE" \
    --argjson collect "$COLLECT" \
    --arg createDate "$CREATE_DATE" \
    --arg from "$EA_FROM_TAG" \
    '{
      accountId: $accountId,
      typeId: $typeId,
      actionId: $actionId,
      money: $money,
      fDate: $fDate,
      note: $note,
      collect: $collect,
      createDate: $createDate,
      from: $from
    }')

  if [[ -n "$ACCOUNT_TO_ID" ]]; then
    BODY=$(echo "$BODY" | jq --argjson aid "$ACCOUNT_TO_ID" '. + {accountToId: $aid}')
  fi

  # 调用 API(单条失败不中断,关闭 errexit)
  set +e
  RESPONSE=$(ea_post "/flow/addFlow" "$BODY" 2>&1)
  RC=$?
  set -e

  if (( RC != 0 )); then
    REASON="第$((i+1))条失败: $RESPONSE"
    echo "❌ $REASON" >&2
    FAILED_LIST=$(echo "$FAILED_LIST" | jq \
      --argjson idx "$i" --arg reason "$REASON" \
      '. + [{index: $idx, reason: $reason}]')
    FAILED_COUNT=$((FAILED_COUNT + 1))
    continue
  fi

  FLOW_ID=$(echo "$RESPONSE" | jq -r '.data.id // empty')
  echo "✅ 第$((i+1))条成功 (flowId=$FLOW_ID, money=$MONEY, note=$NOTE)" >&2

  SUCCESS_LIST=$(echo "$SUCCESS_LIST" | jq \
    --argjson idx "$i" \
    --arg fid "$FLOW_ID" \
    --arg money "$MONEY" \
    '. + [{index: $idx, flowId: ($fid | tonumber? // null), money: $money}]')
  SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
done

# 输出汇总(标准输出,JSON 格式给 AI 解析)
jq -n \
  --argjson total "$TOTAL" \
  --argjson success "$SUCCESS_COUNT" \
  --argjson failed "$FAILED_COUNT" \
  --argjson successList "$SUCCESS_LIST" \
  --argjson failedList "$FAILED_LIST" \
  '{
    success: ($failed == 0),
    message: "批量记账完成: 成功 \($success)/\($total) 条, 失败 \($failed) 条",
    total: $total,
    successCount: $success,
    failedCount: $failed,
    successList: $successList,
    failedList: $failedList
  }'

# 全部失败时返回非 0
if (( SUCCESS_COUNT == 0 )); then
  exit 1
fi


path: scripts/login.sh
content: #!/usr/bin/env bash
# EasyAccounts 登录脚本
#
# 用法:
#   login.sh <username> <password>     # 显式传参
#   login.sh                            # 从环境变量读
#
# 环境变量(可选,显式参数优先):
#   EASYACCOUNTS_USERNAME
#   EASYACCOUNTS_PASSWORD
#
# 成功:保存 token 到 ~/.config/easyaccounts/token
# 失败:退出码非 0,输出错误原因

set -euo pipefail
source "$(dirname "$0")/_common.sh"

ea_check_deps
ea_check_env

USERNAME="${1:-${EASYACCOUNTS_USERNAME:-}}"
[REDACTED_SECRET]"

if [[ -z "$USERNAME" || -z "$PASSWORD" ]]; then
  ea_die "用法: $0 <username> <password>  或设置 EASYACCOUNTS_USERNAME / EASYACCOUNTS_PASSWORD 环境变量"
fi

# 后端要求密码 MD5 hash(与前端 MD5(password).toString() 一致)
HASHED=$(ea_md5 "$PASSWORD")

# 构造 JSON body(用 jq 安全转义)
BODY=$(jq -n --arg u "$USERNAME" --arg p "$HASHED" \
  '{username: $u, password: $p}')

# 调用登录接口(无需 token)
RESPONSE=$(ea_post_noauth "/auth/login" "$BODY")

# 提取 token
[REDACTED_SECRET] "$RESPONSE" | jq -r '.data.token // empty')

if [[ -z "$TOKEN" ]]; then
  ea_die "登录响应未包含 [REDACTED_SECRET]"
fi

# 保存 token
ea_save_token "$TOKEN"

echo "✅ 登录成功,token 已保存到 $EA_TOKEN_FILE"


path: scripts/query_flows.sh
content: #!/usr/bin/env bash
# 查询流水 / 导出 Excel
#
# 用法(查询):
#   query_flows.sh \
#     --handle 1 \
#     [--start-date 2026-03-01] \
#     [--end-date 2026-03-31] \
#     [--account-id 1] \
#     [--types 5,8] \
#     [--note 餐饮] \
#     [--single-month true] \
#     [--analysis true] \
#     [--order-by 2]
#
# 用法(导出 Excel):
#   query_flows.sh --export "2026年3月账单" --handle 1 [其他过滤参数...]
#
# handle: 0=收入 1=支出 2=内部转账 3=全部
# order-by: 0=金额升序 1=金额降序 2=时间排序

set -euo pipefail
source "$(dirname "$0")/_common.sh"

ea_check_deps
ea_check_env

HANDLE=""
ACCOUNT_ID=""
START_DATE=""
END_DATE=""
NOTE=""
SINGLE_MONTH=""
TYPES=""
ANALYSIS="false"
ORDER_BY=""
EXPORT_NAME=""
COLLECT="false"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --handle)       HANDLE="$2"; shift 2 ;;
    --account-id)   ACCOUNT_ID="$2"; shift 2 ;;
    --start-date)   START_DATE="$2"; shift 2 ;;
    --end-date)     END_DATE="$2"; shift 2 ;;
    --note)         NOTE="$2"; shift 2 ;;
    --single-month) SINGLE_MONTH="$2"; shift 2 ;;
    --types)        TYPES="$2"; shift 2 ;;
    --analysis)     ANALYSIS="$2"; shift 2 ;;
    --order-by)     ORDER_BY="$2"; shift 2 ;;
    --export)       EXPORT_NAME="$2"; shift 2 ;;
    --collect)      COLLECT="$2"; shift 2 ;;
    *) ea_die "未知参数: $1" ;;
  esac
done

[[ -n "$HANDLE" ]] || ea_die "缺少 --handle (0=收入,1=支出,2=转账,3=全部)"
if (( HANDLE < 0 || HANDLE > 3 )); then
  ea_die "--handle 必须是 0/1/2/3"
fi

# 构造基础 payload
BODY=$(jq -n \
  --argjson handle "$HANDLE" \
  --arg collect "$COLLECT" \
  '{chooseHandle: $handle, actions: [], collect: $collect}')

# 添加可选参数
add_field() {
  local key="$1" val="$2" type="${3:-string}"
  [[ -z "$val" ]] && return
  if [[ "$type" == "json" ]]; then
    BODY=$(echo "$BODY" | jq --argjson v "$val" ". + {\"$key\": \$v}")
  else
    BODY=$(echo "$BODY" | jq --arg v "$val" ". + {\"$key\": \$v}")
  fi
}

add_field "accountId" "$ACCOUNT_ID" "json"
add_field "startDate" "$START_DATE"
add_field "endDate" "$END_DATE"
add_field "note" "$NOTE"
[[ -n "$SINGLE_MONTH" ]] && BODY=$(echo "$BODY" | jq --argjson v "$SINGLE_MONTH" '. + {singleMonth: $v}')

# types 是逗号分隔的列表 → JSON 数组
if [[ -n "$TYPES" ]]; then
  TYPES_JSON=$(echo "$TYPES" | jq -R 'split(",") | map(tonumber)')
  BODY=$(echo "$BODY" | jq --argjson v "$TYPES_JSON" '. + {types: $v}')
fi

# ============================================================
#                    导出 Excel 分支
# ============================================================
if [[ -n "$EXPORT_NAME" ]]; then
  # excelName 含中文/空格时必须 URL 编码,用 jq 安全编码
  ENCODED_NAME=$(printf '%s' "$EXPORT_NAME" | jq -sRr @uri)
  RESPONSE=$(ea_post "/screen/makeExcel?excelName=${ENCODED_NAME}" "$BODY")
  # 后端返回结构: {data: {success: bool, log: "...file_name: xxx.xlsx"}}
  # 从 log 字符串里解析出文件名
  FILE_NAME=$(echo "$RESPONSE" | jq -r '.data.log // ""' | grep -oE 'file_name: [^ ]+\.xlsx' | sed 's/file_name: //' | head -1)
  echo "$RESPONSE" | jq --arg name "${FILE_NAME:-${EXPORT_NAME}.xlsx}" '{
    success: (.data.success // false),
    message: "Excel 报表生成成功",
    fileName: $name,
    notice: "文件已生成在服务器 Resource/excel/screen/ 目录,请通过前端下载页面或运维拷贝"
  }'
  exit 0
fi

# ============================================================
#                    查询流水分支
# ============================================================
RESPONSE=$(ea_post "/screen/getFlowByScreen" "$BODY")

# 提取汇总
TOTAL_IN=$(echo "$RESPONSE" | jq -r '.data.totalIn // "0"')
TOTAL_OUT=$(echo "$RESPONSE" | jq -r '.data.totalOut // "0"')
TOTAL_EARN=$(echo "$RESPONSE" | jq -r '.data.totalEarn // "0"')

# 提取流水列表
FLOWS=$(echo "$RESPONSE" | jq '.data.flows // []')
TOTAL_COUNT=$(echo "$FLOWS" | jq 'length')

# 排序
case "${ORDER_BY:-}" in
  0) FLOWS=$(echo "$FLOWS" | jq 'sort_by(.money | tonumber)') ;;
  1) FLOWS=$(echo "$FLOWS" | jq 'sort_by(.money | tonumber) | reverse') ;;
  2) FLOWS=$(echo "$FLOWS" | jq 'sort_by(.fdate)') ;;
esac

# 截断到 100 条
MAX=100
IS_TRUNCATED=false
if (( TOTAL_COUNT > MAX )); then
  IS_TRUNCATED=true
  FLOWS=$(echo "$FLOWS" | jq ".[0:$MAX]")
fi

RETURNED=$(echo "$FLOWS" | jq 'length')

# 格式化流水(用 jq 拼字符串)
if [[ "$ANALYSIS" == "true" ]]; then
  FLOWS_FORMATTED=$(echo "$FLOWS" | jq \
    --argjson handle "$HANDLE" \
    --arg totalIn "$TOTAL_IN" \
    --arg totalOut "$TOTAL_OUT" \
    'map(
      "流水ID:\(.id);收支:\(.hname);金额:\(.money);账户:\(.aname);分类:\(.tname);时间:\(.fdate)" +
      (if $handle == 0 and ($totalIn | tonumber) > 0 then ";占比:\(((.money | tonumber) / ($totalIn | tonumber) * 100) | tostring | .[0:5])%" else "" end) +
      (if $handle == 1 and ($totalOut | tonumber) > 0 then ";占比:\(((.money | tonumber) / ($totalOut | tonumber) * 100) | tostring | .[0:5])%" else "" end) +
      (if .toAName then ";转到:\(.toAName)" else "" end) +
      (if .note then ";备注:\(.note)" else "" end)
    )')
else
  FLOWS_FORMATTED=$(echo "$FLOWS" | jq 'map(
    "流水ID:\(.id);收支:\(.hname);金额:\(.money);账户:\(.aname);分类:\(.tname);时间:\(.fdate)" +
    (if .toAName then ";转到:\(.toAName)" else "" end) +
    (if .note then ";备注:\(.note)" else "" end)
  )')
fi

# 输出结果
RESULT=$(jq -n \
  --arg summary "收入=${TOTAL_IN},支出=${TOTAL_OUT},盈余=${TOTAL_EARN}" \
  --argjson flows "$FLOWS_FORMATTED" \
  --argjson total "$TOTAL_COUNT" \
  --argjson returned "$RETURNED" \
  --argjson truncated "$IS_TRUNCATED" \
  '{
    summary: $summary,
    flows: $flows,
    total_count: $total,
    returned_count: $returned,
    is_truncated: $truncated
  }')

if [[ "$IS_TRUNCATED" == "true" ]]; then
  RESULT=$(echo "$RESULT" | jq --arg n "$TOTAL_COUNT" --arg m "$MAX" \
    '. + {notice: "共\($n)条,仅返回前\($m)条,建议使用 make_excel 导出完整报表"}')
fi

echo "$RESULT"


path: scripts/system_info.sh
content: #!/usr/bin/env bash
# 系统信息查询(版本配置 + 公告)
#
# 用法:
#   system_info.sh notices        # 获取项目公告列表
#   system_info.sh version        # 获取版本和系统配置(含云端更新提示)
#   system_info.sh all            # 同时获取两者(默认)
#
# 用途:
# - notices:用户问"有什么公告/通知/项目动态"时使用
# - version:用户问"系统版本/有没有新版/我的配置"时使用
# - all:用户首次使用 skill 或问"系统状态"时使用

set -euo pipefail
source "$(dirname "$0")/_common.sh"

ea_check_deps
ea_check_env

TYPE="${1:-all}"

case "$TYPE" in
  notices|version|all) ;;
  *) ea_die "未知参数: $TYPE  (支持: notices | version | all)" ;;
esac

# 拉取 notices,只保留有效字段
fetch_notices() {
  local raw
  raw=$(ea_get "/home/getNotices")
  echo "$raw" | jq '{
    notices: (.data // [] | map({
      id, title, content, date,
      url: (if .url == "" then null else .url end),
      expire: (if .expire == "" then null else .expire end)
    })),
    count: (.data // [] | length)
  }'
}

# 拉取 version,把更新状态变成易读字段
fetch_version() {
  local raw
  raw=$(ea_get "/home/getVersion")
  echo "$raw" | jq '{
    versions: .data.versions,
    auth: .data.auth,
    backup: .data.backup,
    update: (
      if .data.update == null then
        {hasUpdate: false, message: "当前已是最新版本"}
      else
        {
          hasUpdate: true,
          newVersion: .data.update.version,
          newVersionCode: .data.update.versionCode,
          releaseDate: .data.update.releaseDate,
          changelog: .data.update.changelog,
          currentVersionCode: .data.versions.versionCode
        }
      end
    )
  }'
}

case "$TYPE" in
  notices)
    fetch_notices
    ;;
  version)
    fetch_version
    ;;
  all)
    NOTICES=$(fetch_notices)
    VERSION=$(fetch_version)
    jq -n --argjson notices "$NOTICES" --argjson version "$VERSION" \
      '{notices: $notices, version: $version}'
    ;;
esac


path: scripts/update_flow.sh
content: #!/usr/bin/env bash
# 更新流水(自动追加 Claw更新 标记和 from=Claw)
#
# 用法:
#   update_flow.sh \
#     --flow-id 123 \
#     --account-id 1 \
#     --type-id 5 \
#     --action-id 2 \
#     --money 35.00 \
#     --date 2026-04-01 \
#     --note "公司午餐" \
#     [--account-to-id 2] \
#     [--collect false]

set -euo pipefail
source "$(dirname "$0")/_common.sh"

ea_check_deps
ea_check_env

FLOW_ID=""
ACCOUNT_ID=""
TYPE_ID=""
ACTION_ID=""
MONEY=""
F_DATE=""
NOTE=""
ACCOUNT_TO_ID=""
COLLECT="false"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --flow-id)       FLOW_ID="$2"; shift 2 ;;
    --account-id)    ACCOUNT_ID="$2"; shift 2 ;;
    --type-id)       TYPE_ID="$2"; shift 2 ;;
    --action-id)     ACTION_ID="$2"; shift 2 ;;
    --money)         MONEY="$2"; shift 2 ;;
    --date)          F_DATE="$2"; shift 2 ;;
    --note)          NOTE="$2"; shift 2 ;;
    --account-to-id) ACCOUNT_TO_ID="$2"; shift 2 ;;
    --collect)       COLLECT="$2"; shift 2 ;;
    *) ea_die "未知参数: $1" ;;
  esac
done

[[ -n "$FLOW_ID"    ]] || ea_die "缺少 --flow-id"
[[ -n "$ACCOUNT_ID" ]] || ea_die "缺少 --account-id"
[[ -n "$TYPE_ID"    ]] || ea_die "缺少 --type-id"
[[ -n "$ACTION_ID"  ]] || ea_die "缺少 --action-id"
[[ -n "$MONEY"      ]] || ea_die "缺少 --money"
[[ -n "$F_DATE"     ]] || ea_die "缺少 --date"

MONEY=$(ea_normalize_money "$MONEY")
NOTE=$(ea_append_note_tag "$NOTE" "$EA_NOTE_TAG_UPDATE")
CREATE_DATE=$(ea_now)

BODY=$(jq -n \
  --argjson accountId "$ACCOUNT_ID" \
  --argjson typeId "$TYPE_ID" \
  --argjson actionId "$ACTION_ID" \
  --arg money "$MONEY" \
  --arg fDate "$F_DATE" \
  --arg note "$NOTE" \
  --argjson collect "$COLLECT" \
  --arg createDate "$CREATE_DATE" \
  --arg from "$EA_FROM_TAG" \
  '{
    accountId: $accountId,
    typeId: $typeId,
    actionId: $actionId,
    money: $money,
    fDate: $fDate,
    note: $note,
    collect: $collect,
    createDate: $createDate,
    from: $from
  }')

if [[ -n "$ACCOUNT_TO_ID" ]]; then
  BODY=$(echo "$BODY" | jq --argjson aid "$ACCOUNT_TO_ID" '. + {accountToId: $aid}')
fi

ea_put "/flow/updateFlow/${FLOW_ID}" "$BODY" > /dev/null

jq -n \
  --argjson fid "$FLOW_ID" \
  '{success: true, message: "流水更新成功", flowId: $fid}'


path: scripts/_common.sh
content: #!/usr/bin/env bash
# EasyAccounts skill 公共函数库
# 提供 token 管理、HTTP 调用、错误处理等通用能力
# 用法:在其他脚本里 `source "$(dirname "$0")/_common.sh"`

set -euo pipefail

# ============================================================
#                        配置常量
# ============================================================

EA_CONFIG_DIR="${HOME}/.config/easyaccounts"
EA_TOKEN_FILE="${EA_CONFIG_DIR}/token"

# 来源标记(纯文本,避开后端 JDBC characterEncoding=utf-8 不支持 4 字节 emoji 的问题)
EA_FROM_TAG="Claw"
EA_NOTE_TAG_ADD="#Claw记账"
EA_NOTE_TAG_UPDATE="#Claw更新"

# ============================================================
#                        工具函数
# ============================================================

# 输出错误并退出
ea_die() {
  echo "ERROR: $*" >&2
  exit 1
}

# 检查依赖
ea_check_deps() {
  command -v curl >/dev/null 2>&1 || ea_die "缺少 curl,请先安装"
  command -v jq   >/dev/null 2>&1 || ea_die "缺少 jq,请先安装(brew install jq / apt install jq)"
}

# 检查 EASYACCOUNTS_URL
#
# 严格要求纯净的 baseurl 格式: http(s)://host[:port]
#   - 必须以 http:// 或 https:// 开头
#   - 不允许末尾 /
#   - 不允许任何路径后缀(包括 /api)
#   - 端口可选(https 默认 443、http 默认 80 时可省略)
#
# 合法示例:
#   http://192.168.30.201:10669
#   http://www.lllama.cn:18505
#   https://easy.example.com
#
# 非法示例(会报错):
#   http://192.168.30.201:10669/        ← 末尾斜杠
#   http://192.168.30.201:10669/api     ← 带路径
#   192.168.30.201:10669                ← 缺协议
#
# /api 前缀由脚本内部固定追加,因为 EasyAccounts 是单端口部署,所有 API 都走 nginx 的 /api/ 代理。
ea_check_env() {
  [[ -n "${EASYACCOUNTS_URL:-}" ]] || ea_die "环境变量 EASYACCOUNTS_URL 未设置"

  if [[ ! "$EASYACCOUNTS_URL" =~ ^https?://[^/]+$ ]]; then
    ea_die "EASYACCOUNTS_URL 格式不正确: '$EASYACCOUNTS_URL'
应该是纯净的 http(s)://{host}[:{port}] 格式,不能带末尾斜杠或任何路径。
正确示例: http://192.168.30.201:10669
错误示例: http://192.168.30.201:10669/  或  http://192.168.30.201:10669/api"
  fi
}

# ============================================================
#                       Token 管理
# ============================================================

# 保存 token
ea_save_token() {
  local token="$1"
  mkdir -p "$EA_CONFIG_DIR"
  printf '%s' "$token" > "$EA_TOKEN_FILE"
  chmod 600 "$EA_TOKEN_FILE"
}

# 读取 token
# 不存在时返回空字符串(支持 ENABLE_LOGIN=false 的服务端,无需登录直接调用)
ea_load_token() {
  if [[ -f "$EA_TOKEN_FILE" ]]; then
    cat "$EA_TOKEN_FILE"
  else
    echo ""
  fi
}

# 清除 token
ea_clear_token() {
  rm -f "$EA_TOKEN_FILE"
}

# ============================================================
#                       HTTP 调用封装
# ============================================================

# 把 JSON body 写到临时文件
# 重要:Windows + Git Bash 上,curl -d "$body" 会通过 ANSI code page 传递命令行参数,
# 导致 UTF-8 中文被错误转码为 GBK。改用 --data-binary @file 完全绕过该问题。
_ea_write_body_tmp() {
  local body="$1"
  local tmp
  tmp=$(mktemp -t ea_body.XXXXXX.json)
  printf '%s' "$body" > "$tmp"
  echo "$tmp"
}

# 检查响应是否 401(纯检查,无副作用)
_ea_is_401() {
  local raw="$1"
  local status
  status=$(echo "$raw" | grep -o '__HTTP_STATUS__:[0-9]*' | tail -1 | cut -d: -f2)
  [[ "$status" == "401" || "$status" == "418" ]]
}

# 尝试用环境变量自动登录
# 返回 0 = 成功,1 = 失败(无凭据或登录失败)
# 不打印任何东西到 stdout(避免污染调用方)
_ea_try_auto_login() {
  if [[ -z "${EASYACCOUNTS_USERNAME:-}" || -z "${EASYACCOUNTS_PASSWORD:-}" ]]; then
    return 1
  fi

  local hashed body tmp response status biz_code token
  hashed=$(ea_md5 "$EASYACCOUNTS_PASSWORD")
  body=$(jq -n --arg u "$EASYACCOUNTS_USERNAME" --arg p "$hashed" \
    '{username: $u, password: $p}')
  tmp=$(_ea_write_body_tmp "$body")

  response=$(curl -sS -w '\n__HTTP_STATUS__:%{http_code}' \
    -H "Content-Type: application/json; charset=utf-8" \
    -X POST \
    --data-binary "@$tmp" \
    "${EASYACCOUNTS_URL}/api/auth/login" 2>/dev/null) || { rm -f "$tmp"; return 1; }
  rm -f "$tmp"

  status=$(echo "$response" | grep -o '__HTTP_STATUS__:[0-9]*' | tail -1 | cut -d: -f2)
  [[ "$status" == "200" ]] || return 1

  body=$(echo "$response" | sed 's/__HTTP_STATUS__:[0-9]*$//')
  biz_code=$(echo "$body" | jq -r '.code // 0' 2>/dev/null || echo "0")
  [[ "$biz_code" == "0" ]] || return 1

  [REDACTED_SECRET] "$body" | jq -r '.data.token // empty')
  [[ -n "$token" ]] || return 1

  ea_save_token "$token"
  return 0
}

# 内部:执行一次 HTTP 调用,返回响应
# 用法:_ea_curl <method> <path> [<body>]
# path 是后端 API 路径(如 /auth/login),脚本会自动加 /api 前缀
_ea_curl() {
  local method="$1"
  local path="$2"
  local body="${3:-}"
  local token
  [REDACTED_SECRET])

  if [[ "$method" == "GET" ]]; then
    curl -sS -w '\n__HTTP_STATUS__:%{http_code}' \
      -H "authorization: $token" \
      "${EASYACCOUNTS_URL}/api${path}"
  else
    local tmp
    tmp=$(_ea_write_body_tmp "$body")
    curl -sS -w '\n__HTTP_STATUS__:%{http_code}' \
      -H "authorization: $token" \
      -H "Content-Type: application/json; charset=utf-8" \
      -X "$method" \
      --data-binary "@$tmp" \
      "${EASYACCOUNTS_URL}/api${path}"
    rm -f "$tmp"
  fi
}

# 内部:带 401 自动重试的 HTTP 调用
# 401 时尝试用 env 凭据自动登录,成功后重试一次
_ea_call() {
  local method="$1"
  local path="$2"
  local body="${3:-}"

  local response
  response=$(_ea_curl "$method" "$path" "$body")

  if _ea_is_401 "$response" && _ea_try_auto_login; then
    response=$(_ea_curl "$method" "$path" "$body")
  fi

  ea_handle_response "$response"
}

# 公共 HTTP 接口
ea_get() { _ea_call "GET" "$1"; }
ea_post() { _ea_call "POST" "$1" "$2"; }
ea_put() { _ea_call "PUT" "$1" "$2"; }

# 不带 token 的 POST(用于 login)
# 用法:ea_post_noauth <path> <json_body>
ea_post_noauth() {
  local path="$1"
  local body="$2"

  local tmp
  tmp=$(_ea_write_body_tmp "$body")

  local response
  response=$(curl -sS -w '\n__HTTP_STATUS__:%{http_code}' \
    -H "Content-Type: application/json; charset=utf-8" \
    -X POST \
    --data-binary "@$tmp" \
    "${EASYACCOUNTS_URL}/api${path}")

  rm -f "$tmp"
  ea_handle_response "$response"
}

# 处理 HTTP 响应
# 输入格式:<body>\n__HTTP_STATUS__:<code>
# 输出:JSON body(失败时退出)
ea_handle_response() {
  local raw="$1"
  local status body
  status=$(echo "$raw" | grep -o '__HTTP_STATUS__:[0-9]*' | tail -1 | cut -d: -f2)
  body=$(echo "$raw" | sed 's/__HTTP_STATUS__:[0-9]*$//')

  case "$status" in
    200|201)
      # HTTP OK,但还要检查业务 code
      local biz_code
      biz_code=$(echo "$body" | jq -r '.code // 0' 2>/dev/null || echo "0")
      if [[ "$biz_code" != "0" ]]; then
        local msg
        msg=$(echo "$body" | jq -r '.msg // "未知错误"' 2>/dev/null || echo "未知错误")
        ea_die "业务错误[$biz_code]: $msg"
      fi
      echo "$body"
      ;;
    401|418)
      ea_clear_token
      ea_die "认证失败(HTTP $status)。可能原因:1) 服务端开启了登录但未提供 token;2) token 已过期(默认 30 分钟)。请运行 login.sh 重新登录(需要用户名密码)。"
      ;;
    *)
      ea_die "HTTP $status: $body"
      ;;
  esac
}

# ============================================================
#                       JSON 构造辅助
# ============================================================

# 处理金额:去掉负号
ea_normalize_money() {
  local money="$1"
  echo "${money#-}"
}

# 给备注追加标记(如未包含)
# 用法:ea_append_note_tag <note> <tag>
ea_append_note_tag() {
  local note="$1"
  local tag="$2"
  if [[ -z "$note" ]]; then
    echo "$tag"
  elif [[ "$note" == *"$tag"* ]]; then
    echo "$note"
  else
    echo "$note $tag"
  fi
}

# 当前时间戳(yyyy-MM-dd HH:mm:ss)
ea_now() {
  date '+%Y-%m-%d %H:%M:%S'
}

# MD5 哈希(小写 hex)
# 用法:ea_md5 <明文字符串>
# 优先 md5sum(Linux/Git Bash 内置),fallback 到 openssl
ea_md5() {
  local input="$1"
  if command -v md5sum >/dev/null 2>&1; then
    printf '%s' "$input" | md5sum | awk '{print $1}'
  elif command -v openssl >/dev/null 2>&1; then
    printf '%s' "$input" | openssl md5 | awk '{print $NF}'
  else
    ea_die "缺少 md5sum 或 openssl,无法计算密码 hash"
  fi
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)