diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index 0bd96205..00000000 --- a/SKILL.md +++ /dev/null @@ -1,879 +0,0 @@ ---- -name: opencli -description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login." -version: 1.5.5 -author: jackwener -tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent] ---- - -# OpenCLI - -> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered discovery. - -> [!CAUTION] -> **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)!** -> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。 -> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。** - -> [!IMPORTANT] -> 创建或修改 adapter 时,再额外遵守 3 条收口规则: -> 1. 主参数优先用 positional arg,不要把 `query` / `id` / `url` 默认做成 `--query` / `--id` / `--url` -> 2. 预期中的 adapter 失败优先抛 `CliError` 子类,不要直接 throw 原始 `Error` -> 3. 新增 adapter 或新增用户可发现命令时,同步更新 adapter docs、`docs/adapters/index.md`、sidebar,以及 README/README.zh-CN 中受影响的入口 - -## Install & Run - -```bash -# npm global install (recommended) -npm install -g @jackwener/opencli -opencli - -# Or from source -cd ~/code/opencli && npm install -npx tsx src/main.ts - -# Update to latest -npm update -g @jackwener/opencli -``` - -## Prerequisites - -Browser commands require: -1. Chrome browser running **(logged into target sites)** -2. **opencli Browser Bridge** Chrome extension installed (load `extension/` as unpacked in `chrome://extensions`) -3. No further setup needed — the daemon auto-starts on first browser command - -> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards. - -Public API commands (`hackernews`, `v2ex`) need no browser. - -## Commands Reference - -### Data Commands - -```bash -# Bilibili (browser) -opencli bilibili hot --limit 10 # B站热门视频 -opencli bilibili search "rust" # 搜索视频 (query positional) -opencli bilibili me # 我的信息 -opencli bilibili favorite # 我的收藏 -opencli bilibili history --limit 20 # 观看历史 -opencli bilibili feed --limit 10 # 动态时间线 -opencli bilibili user-videos --uid 12345 # 用户投稿 -opencli bilibili subtitle --bvid BV1xxx # 获取视频字幕 (支持 --lang zh-CN) -opencli bilibili dynamic --limit 10 # 动态 -opencli bilibili ranking --limit 10 # 排行榜 -opencli bilibili following --limit 20 # 我的关注列表 (支持 --uid 查看他人) - -# 知乎 (browser) -opencli zhihu hot --limit 10 # 知乎热榜 -opencli zhihu search "AI" # 搜索 (query positional) -opencli zhihu question 34816524 # 问题详情和回答 (id positional) - -# 小红书 (browser) -opencli xiaohongshu search "美食" # 搜索笔记 (query positional) -opencli xiaohongshu notifications # 通知(mentions/likes/connections) -opencli xiaohongshu feed --limit 10 # 推荐 Feed -opencli xiaohongshu user xxx # 用户主页 (id positional) -opencli xiaohongshu creator-notes --limit 10 # 创作者笔记列表 -opencli xiaohongshu creator-note-detail --note-id xxx # 笔记详情 -opencli xiaohongshu creator-notes-summary # 笔记数据概览 -opencli xiaohongshu creator-profile # 创作者资料 -opencli xiaohongshu creator-stats # 创作者数据统计 - -# 雪球 Xueqiu (browser) -opencli xueqiu hot-stock --limit 10 # 雪球热门股票榜 -opencli xueqiu stock --symbol SH600519 # 查看股票实时行情 -opencli xueqiu watchlist # 获取自选股/持仓列表 -opencli xueqiu feed # 我的关注 timeline -opencli xueqiu hot --limit 10 # 雪球热榜 -opencli xueqiu search "特斯拉" # 搜索 (query positional) -opencli xueqiu earnings-date SH600519 # 股票财报发布日期 (symbol positional) -opencli xueqiu fund-holdings # 蛋卷基金持仓明细 (支持 --account 过滤) -opencli xueqiu fund-snapshot # 蛋卷基金快照(总资产、子账户、持仓) - -# GitHub (via gh External CLI) -opencli gh repo list # 列出仓库 (passthrough to gh) -opencli gh pr list --limit 5 # PR 列表 -opencli gh issue list # Issue 列表 - -# Twitter/X (browser) -opencli twitter trending --limit 10 # 热门话题 -opencli twitter bookmarks --limit 20 # 获取收藏的书签推文 -opencli twitter search "AI" # 搜索推文 (query positional) -opencli twitter profile elonmusk # 用户资料 -opencli twitter timeline --limit 20 # 时间线 -opencli twitter thread 1234567890 # 推文 thread(原文 + 回复) -opencli twitter article 1891511252174299446 # 推文长文内容 -opencli twitter follow elonmusk # 关注用户 -opencli twitter unfollow elonmusk # 取消关注 -opencli twitter bookmark https://x.com/... # 收藏推文 -opencli twitter unbookmark https://x.com/... # 取消收藏 -opencli twitter post "Hello world" # 发布推文 (text positional) -opencli twitter like https://x.com/... # 点赞推文 (url positional) -opencli twitter reply https://x.com/... "Nice!" # 回复推文 (url + text positional) -opencli twitter delete https://x.com/... # 删除推文 (url positional) -opencli twitter block elonmusk # 屏蔽用户 (username positional) -opencli twitter unblock elonmusk # 取消屏蔽 (username positional) -opencli twitter followers elonmusk # 用户的粉丝列表 (user positional) -opencli twitter following elonmusk # 用户的关注列表 (user positional) -opencli twitter notifications --limit 20 # 通知列表 -opencli twitter hide-reply https://x.com/... # 隐藏回复 (url positional) -opencli twitter download elonmusk # 下载用户媒体 (username positional, 支持 --tweet-url) -opencli twitter accept "群,微信" # 自动接受含关键词的 DM 请求 (query positional) -opencli twitter reply-dm "消息内容" # 批量回复 DM (text positional) - -# Reddit (browser) -opencli reddit hot --limit 10 # 热门帖子 -opencli reddit hot --subreddit programming # 指定子版块 -opencli reddit frontpage --limit 10 # 首页 /r/all -opencli reddit popular --limit 10 # /r/popular 热门 -opencli reddit search "AI" --sort top --time week # 搜索(支持排序+时间过滤) -opencli reddit subreddit rust --sort top --time month # 子版块浏览(支持时间过滤) -opencli reddit read --post-id 1abc123 # 阅读帖子 + 评论 -opencli reddit user spez # 用户资料(karma、注册时间) -opencli reddit user-posts spez # 用户发帖历史 -opencli reddit user-comments spez # 用户评论历史 -opencli reddit upvote --post-id xxx --direction up # 投票(up/down/none) -opencli reddit save --post-id xxx # 收藏帖子 -opencli reddit comment --post-id xxx "Great!" # 发表评论 (text positional) -opencli reddit subscribe --subreddit python # 订阅子版块 -opencli reddit saved --limit 10 # 我的收藏 -opencli reddit upvoted --limit 10 # 我的赞 - -# V2EX (public + browser) -opencli v2ex hot --limit 10 # 热门话题 -opencli v2ex latest --limit 10 # 最新话题 -opencli v2ex topic 1024 # 主题详情 (id positional) -opencli v2ex daily # 每日签到 (browser) -opencli v2ex me # 我的信息 (browser) -opencli v2ex notifications --limit 10 # 通知 (browser) -opencli v2ex node python # 节点话题列表 (name positional) -opencli v2ex nodes --limit 30 # 所有节点列表 -opencli v2ex member username # 用户资料 (username positional) -opencli v2ex user username # 用户发帖列表 (username positional) -opencli v2ex replies 1024 # 主题回复列表 (id positional) - -# Hacker News (public) -opencli hackernews top --limit 10 # Top stories -opencli hackernews new --limit 10 # Newest stories -opencli hackernews best --limit 10 # Best stories -opencli hackernews ask --limit 10 # Ask HN posts -opencli hackernews show --limit 10 # Show HN posts -opencli hackernews jobs --limit 10 # Job postings -opencli hackernews search "rust" # 搜索 (query positional) -opencli hackernews user dang # 用户资料 (username positional) - -# BBC (public) -opencli bbc news --limit 10 # BBC News RSS headlines - -# 微博 (browser) -opencli weibo hot --limit 10 # 微博热搜 - -# BOSS直聘 (browser) -opencli boss search "AI agent" # 搜索职位 (query positional) -opencli boss detail --security-id xxx # 职位详情 -opencli boss recommend --limit 10 # 推荐职位 -opencli boss joblist --limit 10 # 职位列表 -opencli boss greet --security-id xxx # 打招呼 -opencli boss batchgreet --job-id xxx # 批量打招呼 -opencli boss send --uid xxx "消息内容" # 发消息 (text positional) -opencli boss chatlist --limit 10 # 聊天列表 -opencli boss chatmsg --security-id xxx # 聊天记录 -opencli boss invite --security-id xxx # 邀请沟通 -opencli boss mark --security-id xxx # 标记管理 -opencli boss exchange --security-id xxx # 交换联系方式 -opencli boss resume # 简历管理 -opencli boss stats # 数据统计 - -# YouTube (browser) -opencli youtube search "rust" # 搜索视频 (query positional) -opencli youtube video "https://www.youtube.com/watch?v=xxx" # 视频元数据 -opencli youtube transcript "https://www.youtube.com/watch?v=xxx" # 获取视频字幕/转录 -opencli youtube transcript "xxx" --lang zh-Hans --mode raw # 指定语言 + 原始时间戳模式 - -# Yahoo Finance (browser) -opencli yahoo-finance quote --symbol AAPL # 股票行情 - -# Sina Finance -opencli sinafinance news --limit 10 --type 1 # 7x24实时快讯 (0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它) - -# Reuters (browser) -opencli reuters search "AI" # 路透社搜索 (query positional) - -# 什么值得买 (browser) -opencli smzdm search "耳机" # 搜索好价 (query positional) - -# 携程 (browser) -opencli ctrip search "三亚" # 搜索目的地 (query positional) - -# Antigravity (Electron/CDP) -opencli antigravity status # 检查 CDP 连接 -opencli antigravity send "hello" # 发送文本到当前 agent 聊天框 -opencli antigravity read # 读取整个聊天记录面板 -opencli antigravity new # 清空聊天、开启新对话 -opencli antigravity dump # 导出 DOM 和快照调试信息 -opencli antigravity extract-code # 自动抽取 AI 回复中的代码块 -opencli antigravity model claude # 切换底层模型 -opencli antigravity watch # 流式监听增量消息 - -# Barchart (browser) -opencli barchart quote --symbol AAPL # 股票行情 -opencli barchart options --symbol AAPL # 期权链 -opencli barchart greeks --symbol AAPL # 期权 Greeks -opencli barchart flow --limit 20 # 异常期权活动 - -# Jike 即刻 (browser) -opencli jike feed --limit 10 # 动态流 -opencli jike search "AI" # 搜索 (query positional) -opencli jike create "内容" # 发布动态 (text positional) -opencli jike like xxx # 点赞 (id positional) -opencli jike comment xxx "评论" # 评论 (id + text positional) -opencli jike repost xxx # 转发 (id positional) -opencli jike notifications # 通知 - -# Linux.do (public + browser) -opencli linux-do hot --limit 10 # 热门话题 -opencli linux-do latest --limit 10 # 最新话题 -opencli linux-do search "rust" # 搜索 (query positional) -opencli linux-do topic 1024 # 主题详情 (id positional) -opencli linux-do categories --limit 20 # 分类列表 (browser) -opencli linux-do category dev 7 # 分类内话题 (slug + id positional, browser) - -# StackOverflow (public) -opencli stackoverflow hot --limit 10 # 热门问题 -opencli stackoverflow search "typescript" # 搜索 (query positional) -opencli stackoverflow bounties --limit 10 # 悬赏问题 - -# WeRead 微信读书 (browser) -opencli weread shelf --limit 10 # 书架 -opencli weread search "AI" # 搜索图书 (query positional) -opencli weread book xxx # 图书详情 (book-id positional) -opencli weread highlights xxx # 划线笔记 (book-id positional) -opencli weread notes xxx # 想法笔记 (book-id positional) -opencli weread ranking --limit 10 # 排行榜 - -# Jimeng 即梦 AI (browser) -opencli jimeng generate --prompt "描述" # AI 生图 -opencli jimeng history --limit 10 # 生成历史 - -# Yollomi yollomi.com (browser — 需在 Chrome 登录 yollomi.com,复用站点 session) -opencli yollomi models --type image # 列出图像模型与积分 -opencli yollomi generate "提示词" --model z-image-turbo # 文生图 -opencli yollomi video "提示词" --model kling-2-1 # 视频 -opencli yollomi upload ./photo.jpg # 上传得 URL,供 img2img / 工具链使用 -opencli yollomi remove-bg # 去背景(免费) -opencli yollomi edit "改成油画风格" # Qwen 图像编辑 -opencli yollomi background # AI 背景生成 (5 credits) -opencli yollomi face-swap --source --target # 换脸 (3 credits) -opencli yollomi object-remover # AI 去除物体 (3 credits) -opencli yollomi restore # AI 修复老照片 (4 credits) -opencli yollomi try-on --person --cloth # 虚拟试衣 (3 credits) -opencli yollomi upscale # AI 超分辨率 (1 credit, 支持 --scale 2/4) - -# Grok (default + explicit web) -opencli grok ask --prompt "问题" # 提问 Grok(兼容默认路径) -opencli grok ask --prompt "问题" --web # 显式 grok.com consumer web UI 路径 - -# HuggingFace (public) -opencli hf top --limit 10 # 热门模型 - -# 超星学习通 (browser) -opencli chaoxing assignments # 作业列表 -opencli chaoxing exams # 考试列表 - -# Douban 豆瓣 (browser) -opencli douban search "三体" # 搜索 (query positional) -opencli douban top250 # 豆瓣 Top 250 -opencli douban subject 1234567 # 条目详情 (id positional) -opencli douban photos 30382501 # 图片列表 / 直链(默认海报) -opencli douban download 30382501 # 下载海报 / 剧照 -opencli douban marks --limit 10 # 我的标记 -opencli douban reviews --limit 10 # 短评 - -# Facebook (browser) -opencli facebook feed --limit 10 # 动态流 -opencli facebook profile username # 用户资料 (id positional) -opencli facebook search "AI" # 搜索 (query positional) -opencli facebook friends # 好友列表 -opencli facebook groups # 群组 -opencli facebook events # 活动 -opencli facebook notifications # 通知 -opencli facebook memories # 回忆 -opencli facebook add-friend username # 添加好友 (id positional) -opencli facebook join-group groupid # 加入群组 (id positional) - -# Instagram (browser) -opencli instagram explore # 探索 -opencli instagram profile username # 用户资料 (id positional) -opencli instagram search "AI" # 搜索 (query positional) -opencli instagram user username # 用户详情 (id positional) -opencli instagram followers username # 粉丝 (id positional) -opencli instagram following username # 关注 (id positional) -opencli instagram follow username # 关注用户 (id positional) -opencli instagram unfollow username # 取消关注 (id positional) -opencli instagram like postid # 点赞 (id positional) -opencli instagram unlike postid # 取消点赞 (id positional) -opencli instagram comment postid "评论" # 评论 (id + text positional) -opencli instagram save postid # 收藏 (id positional) -opencli instagram unsave postid # 取消收藏 (id positional) -opencli instagram saved # 已收藏列表 - -# TikTok (browser) -opencli tiktok explore # 探索 -opencli tiktok search "AI" # 搜索 (query positional) -opencli tiktok profile username # 用户资料 (id positional) -opencli tiktok user username # 用户详情 (id positional) -opencli tiktok following username # 关注列表 (id positional) -opencli tiktok follow username # 关注 (id positional) -opencli tiktok unfollow username # 取消关注 (id positional) -opencli tiktok like videoid # 点赞 (id positional) -opencli tiktok unlike videoid # 取消点赞 (id positional) -opencli tiktok comment videoid "评论" # 评论 (id + text positional) -opencli tiktok save videoid # 收藏 (id positional) -opencli tiktok unsave videoid # 取消收藏 (id positional) -opencli tiktok live # 直播 -opencli tiktok notifications # 通知 -opencli tiktok friends # 朋友 - -# Medium (browser) -opencli medium feed --limit 10 # 动态流 -opencli medium search "AI" # 搜索 (query positional) -opencli medium user username # 用户主页 (id positional) - -# Substack (browser) -opencli substack feed --limit 10 # 订阅动态 -opencli substack search "AI" # 搜索 (query positional) -opencli substack publication name # 出版物详情 (id positional) - -# Sinablog 新浪博客 (browser) -opencli sinablog hot --limit 10 # 热门 -opencli sinablog search "AI" # 搜索 (query positional) -opencli sinablog article url # 文章详情 -opencli sinablog user username # 用户主页 (id positional) - -# Lobsters (public) -opencli lobsters hot --limit 10 # 热门 -opencli lobsters newest --limit 10 # 最新 -opencli lobsters active --limit 10 # 活跃 -opencli lobsters tag rust # 按标签筛选 (tag positional) - -# Google (public) -opencli google news --limit 10 # 新闻 -opencli google search "AI" # 搜索 (query positional) -opencli google suggest "AI" # 搜索建议 (query positional) -opencli google trends # 趋势 - -# DEV.to (public) -opencli devto top --limit 10 # 热门文章 -opencli devto tag javascript --limit 10 # 按标签 (tag positional) -opencli devto user username # 用户文章 (username positional) - -# Steam (public) -opencli steam top-sellers --limit 10 # 热销游戏 - -# Apple Podcasts (public) -opencli apple-podcasts top --limit 10 # 热门播客排行榜 (支持 --country us/cn/gb/jp) -opencli apple-podcasts search "科技" # 搜索播客 (query positional) -opencli apple-podcasts episodes 12345 # 播客剧集列表 (id positional, 用 search 获取 ID) - -# arXiv (public) -opencli arxiv search "attention" # 搜索论文 (query positional) -opencli arxiv paper 1706.03762 # 论文详情 (id positional) - -# Bloomberg (public RSS + browser) -opencli bloomberg main --limit 10 # Bloomberg 首页头条 (RSS) -opencli bloomberg markets --limit 10 # 市场新闻 (RSS) -opencli bloomberg tech --limit 10 # 科技新闻 (RSS) -opencli bloomberg politics --limit 10 # 政治新闻 (RSS) -opencli bloomberg economics --limit 10 # 经济新闻 (RSS) -opencli bloomberg opinions --limit 10 # 观点 (RSS) -opencli bloomberg industries --limit 10 # 行业新闻 (RSS) -opencli bloomberg businessweek --limit 10 # Businessweek (RSS) -opencli bloomberg feeds # 列出所有 RSS feed 别名 -opencli bloomberg news "https://..." # 阅读 Bloomberg 文章全文 (link positional, browser) - -# Coupang 쿠팡 (browser) -opencli coupang search "耳机" # 搜索商品 (query positional, 支持 --filter rocket) -opencli coupang add-to-cart 12345 # 加入购物车 (product-id positional, 或 --url) - -# Dictionary (public) -opencli dictionary search "serendipity" # 单词释义 (word positional) -opencli dictionary synonyms "happy" # 近义词 (word positional) -opencli dictionary examples "ubiquitous" # 例句 (word positional) - -# 豆包 Doubao Web (browser) -opencli doubao status # 检查豆包页面状态 -opencli doubao new # 新建对话 -opencli doubao send "你好" # 发送消息 (text positional) -opencli doubao read # 读取对话记录 -opencli doubao ask "问题" # 一键提问并等回复 (text positional) - -# 京东 JD (browser) -opencli jd item 100291143898 # 商品详情 (sku positional, 含价格/主图/规格) - -# LinkedIn (browser) -opencli linkedin search "AI engineer" # 搜索职位 (query positional, 支持 --location/--company/--remote) -opencli linkedin timeline --limit 20 # 首页动态流 - -# Pixiv (browser) -opencli pixiv ranking --limit 20 # 插画排行榜 (支持 --mode daily/weekly/monthly) -opencli pixiv search "風景" # 搜索插画 (query positional) -opencli pixiv user 12345 # 画师资料 (uid positional) -opencli pixiv illusts 12345 # 画师作品列表 (user-id positional) -opencli pixiv detail 12345 # 插画详情 (id positional) -opencli pixiv download 12345 # 下载插画 (illust-id positional) - -# Web (browser) -opencli web read --url "https://..." # 抓取任意网页并导出为 Markdown - -# 微信公众号 Weixin (browser) -opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" # 下载公众号文章为 Markdown - -# 小宇宙 Xiaoyuzhou (public) -opencli xiaoyuzhou podcast 12345 # 播客资料 (id positional) -opencli xiaoyuzhou podcast-episodes 12345 # 播客剧集列表 (id positional) -opencli xiaoyuzhou episode 12345 # 单集详情 (id positional) - -# Wikipedia (public) -opencli wikipedia search "AI" # 搜索 (query positional) -opencli wikipedia summary "Python" # 摘要 (title positional) -``` - -### Desktop Adapter Commands - -```bash -# Cursor (desktop — CDP via Electron) -opencli cursor status # 检查连接 -opencli cursor send "message" # 发送消息 -opencli cursor read # 读取回复 -opencli cursor new # 新建对话 -opencli cursor dump # 导出 DOM 调试信息 -opencli cursor composer # Composer 模式 -opencli cursor model claude # 切换模型 -opencli cursor extract-code # 提取代码块 -opencli cursor ask "question" # 一键提问并等回复 -opencli cursor screenshot # 截图 -opencli cursor history # 对话历史 -opencli cursor export # 导出对话 - -# Codex (desktop — headless CLI agent) -opencli codex status # 检查连接 -opencli codex send "message" # 发送消息 -opencli codex read # 读取回复 -opencli codex new # 新建对话 -opencli codex dump # 导出调试信息 -opencli codex extract-diff # 提取 diff -opencli codex model gpt-4 # 切换模型 -opencli codex ask "question" # 一键提问并等回复 -opencli codex screenshot # 截图 -opencli codex history # 对话历史 -opencli codex export # 导出对话 - -# ChatGPT (desktop — macOS AppleScript/CDP) -opencli chatgpt status # 检查应用状态 -opencli chatgpt new # 新建对话 -opencli chatgpt send "message" # 发送消息 -opencli chatgpt read # 读取回复 -opencli chatgpt ask "question" # 一键提问并等回复 - -# ChatWise (desktop — multi-LLM client) -opencli chatwise status # 检查连接 -opencli chatwise new # 新建对话 -opencli chatwise send "message" # 发送消息 -opencli chatwise read # 读取回复 -opencli chatwise ask "question" # 一键提问并等回复 -opencli chatwise model claude # 切换模型 -opencli chatwise history # 对话历史 -opencli chatwise export # 导出对话 -opencli chatwise screenshot # 截图 - -# Notion (desktop — CDP via Electron) -opencli notion status # 检查连接 -opencli notion search "keyword" # 搜索页面 -opencli notion read # 读取当前页面 -opencli notion new # 新建页面 -opencli notion write "content" # 写入内容 -opencli notion sidebar # 侧边栏导航 -opencli notion favorites # 收藏列表 -opencli notion export # 导出 - -# Discord App (desktop — CDP via Electron) -opencli discord-app status # 检查连接 -opencli discord-app send "message" # 发送消息 -opencli discord-app read # 读取消息 -opencli discord-app channels # 频道列表 -opencli discord-app servers # 服务器列表 -opencli discord-app search "keyword" # 搜索 -opencli discord-app members # 成员列表 - -# Doubao App 豆包桌面版 (desktop — CDP via Electron) -opencli doubao-app status # 检查连接 -opencli doubao-app new # 新建对话 -opencli doubao-app send "message" # 发送消息 -opencli doubao-app read # 读取回复 -opencli doubao-app ask "question" # 一键提问并等回复 -opencli doubao-app screenshot # 截图 -opencli doubao-app dump # 导出 DOM 调试信息 -``` - -### Management Commands - -```bash -opencli list # List all commands (including External CLIs) -opencli list --json # JSON output -opencli list -f yaml # YAML output -opencli install # Auto-install an external CLI (e.g., gh, obsidian) -opencli register # Register a local custom CLI for unified discovery -opencli validate # Validate all CLI definitions -opencli validate bilibili # Validate specific site -opencli doctor # Diagnose browser bridge (auto-starts daemon, includes live test) -``` - -### AI Agent Workflow - -```bash -# Deep Explore: network intercept → response analysis → capability inference -opencli explore --site - -# Synthesize: generate evaluate-based YAML pipelines from explore artifacts -opencli synthesize - -# Generate: one-shot explore → synthesize → register -opencli generate --goal "hot" - -# Record: YOU operate the page, opencli captures every API call → YAML candidates -# Opens the URL in automation window, injects fetch/XHR interceptor into ALL tabs, -# polls every 2s, auto-stops after 60s (or press Enter to stop early). -opencli record # 录制,site name 从域名推断 -opencli record --site mysite # 指定 site name -opencli record --timeout 120000 # 自定义超时(毫秒,默认 60000) -opencli record --poll 1000 # 缩短轮询间隔(毫秒,默认 2000) -opencli record --out .opencli/record/x # 自定义输出目录 -# Output: -# .opencli/record//captured.json ← 原始捕获数据(带 url/method/body) -# .opencli/record//candidates/*.yaml ← 高置信度候选适配器(score ≥ 8,有 array 结果) - -# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER -opencli cascade - -# Explore with interactive fuzzing (click buttons to trigger lazy APIs) -opencli explore --auto --click "字幕,CC,评论" - -# Validate: validate adapter definitions -opencli validate -``` - -## Output Formats - -All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`. -The `list` command supports the same formats and also keeps `--json` as a compatibility alias. - -```bash -opencli list -f yaml # YAML command registry -opencli bilibili hot -f table # Default: rich table -opencli bilibili hot -f json # JSON (pipe to jq, feed to AI agent) -opencli bilibili hot -f yaml # YAML (readable structured output) -opencli bilibili hot -f md # Markdown -opencli bilibili hot -f csv # CSV -``` - -## Verbose Mode - -```bash -opencli bilibili hot -v # Show each pipeline step and data flow -``` - -## Record Workflow - -`record` 是为「无法用 `explore` 自动发现」的页面(需要登录操作、复杂交互、SPA 内路由)准备的手动录制方案。 - -### 工作原理 - -``` -opencli record - → 打开 automation window 并导航到目标 URL - → 向所有 tab 注入 fetch/XHR 拦截器(幂等,可重复注入) - → 每 2s 轮询一次:发现新 tab 自动注入,drain 所有 tab 的捕获缓冲区 - → 超时(默认 60s)或按 Enter 停止 - → 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 YAML -``` - -**拦截器特性**: -- 同时 patch `window.fetch` 和 `XMLHttpRequest` -- 只捕获 `Content-Type: application/json` 的响应 -- 过滤纯对象少于 2 个 key 的响应(避免 tracking/ping) -- 跨 tab 隔离:每个 tab 独立缓冲区,轮询时分别 drain -- 幂等注入:同一 tab 二次注入时先 restore 原始函数再重新 patch,不丢失已捕获数据 - -### 使用步骤 - -```bash -# 1. 启动录制(建议 --timeout 给足操作时间) -opencli record "https://example.com/page" --timeout 120000 - -# 2. 在弹出的 automation window 里正常操作页面: -# - 打开列表、搜索、点击条目、切换 Tab -# - 凡是触发网络请求的操作都会被捕获 - -# 3. 完成操作后按 Enter 停止(或等超时自动停止) - -# 4. 查看结果 -cat .opencli/record//captured.json # 原始捕获 -ls .opencli/record//candidates/ # 候选 YAML -``` - -### 页面类型与捕获预期 - -| 页面类型 | 预期捕获量 | 说明 | -|---------|-----------|------| -| 列表/搜索页 | 多(5~20+) | 每次搜索/翻页都会触发新请求 | -| 详情页(只读) | 少(1~5) | 首屏数据一次性返回,后续操作走 form/redirect | -| SPA 内路由跳转 | 中等 | 路由切换会触发新接口,但首屏请求在注入前已发出 | -| 需要登录的页面 | 视操作而定 | 确保 Chrome 已登录目标网站 | - -> **注意**:如果页面在导航完成前就发出了大部分请求(服务端渲染 / SSR 注水),拦截器会错过这些请求。 -> 解决方案:在页面加载完成后,手动触发能产生新请求的操作(搜索、翻页、切 Tab、展开折叠项等)。 - -### 候选 YAML → TS CLI 转换 - -生成的候选 YAML 是起点,通常需要转换为 TypeScript(尤其是 tae 等内部系统): - -**候选 YAML 结构**(自动生成): -```yaml -site: tae -name: getList # 从 URL path 推断的名称 -strategy: cookie -browser: true -pipeline: - - navigate: https://... - - evaluate: | - (async () => { - const res = await fetch('/approval/getList.json?procInsId=...', { credentials: 'include' }); - const data = await res.json(); - return (data?.content?.operatorRecords || []).map(item => ({ ... })); - })() -``` - -**转换为 TS CLI**(参考 `src/clis/tae/add-expense.ts` 风格): -```typescript -import { cli, Strategy } from '../../registry.js'; - -cli({ - site: 'tae', - name: 'get-approval', - description: '查看报销单审批流程和操作记录', - domain: 'tae.alibaba-inc.com', - strategy: Strategy.COOKIE, - browser: true, - args: [ - { name: 'proc_ins_id', type: 'string', required: true, positional: true, help: '流程实例 ID(procInsId)' }, - ], - columns: ['step', 'operator', 'action', 'time'], - func: async (page, kwargs) => { - await page.goto('https://tae.alibaba-inc.com/expense/pc.html?_authType=SAML'); - await page.wait(2); - const result = await page.evaluate(`(async () => { - const res = await fetch('/approval/getList.json?taskId=&procInsId=${kwargs.proc_ins_id}', { - credentials: 'include' - }); - const data = await res.json(); - return data?.content?.operatorRecords || []; - })()`); - return (result as any[]).map((r, i) => ({ - step: i + 1, - operator: r.operatorName || r.userId, - action: r.operationType, - time: r.operateTime, - })); - }, -}); -``` - -**转换要点**: -1. URL 中的动态 ID(`procInsId`、`taskId` 等)提取为 `args` -2. `captured.json` 里的真实 body 结构用于确定正确的数据路径(如 `content.operatorRecords`) -3. tae 系统统一用 `{ success, content, errorCode, errorMsg }` 外层包裹,取数据要走 `content.*` -4. 认证方式:cookie(`credentials: 'include'`),不需要额外 header -5. 文件放入 `src/clis//`,无需手动注册,`npm run build` 后自动发现 - -### 故障排查 - -| 现象 | 原因 | 解法 | -|------|------|------| -| 捕获 0 条请求 | 拦截器注入失败,或页面无 JSON API | 检查 daemon 是否运行:`curl localhost:19825/status` | -| 捕获量少(1~3 条) | 页面是只读详情页,首屏数据已在注入前发出 | 手动操作触发更多请求(搜索/翻页),或换用列表页 | -| 候选 YAML 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI | -| 新开的 tab 没有被拦截 | 轮询间隔内 tab 已关闭 | 缩短 `--poll 500` | -| 二次运行 record 时数据不连续 | 正常,每次 `record` 启动都是新的 automation window | 无需处理 | - -## Creating Adapters - -> [!TIP] -> **快速模式**:如果你只想为一个具体页面生成一个命令,直接看 [CLI-ONESHOT.md](./CLI-ONESHOT.md)。 -> 只需要一个 URL + 一句话描述,4 步搞定。 - -> [!IMPORTANT] -> **完整模式 — 在写任何代码之前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。** -> 它包含:① AI Agent 浏览器探索工作流 ② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。 -> **下方仅为简化模板参考,直接使用极易踩坑。** - -### YAML Pipeline (declarative, recommended) - -Create `src/clis//.yaml`: - -```yaml -site: mysite -name: hot -description: Hot topics -domain: www.mysite.com -strategy: cookie # public | cookie | header | intercept | ui -browser: true - -args: - limit: - type: int - default: 20 - description: Number of items - -pipeline: - - navigate: https://www.mysite.com - - - evaluate: | - (async () => { - const res = await fetch('/api/hot', { credentials: 'include' }); - const d = await res.json(); - return d.data.items.map(item => ({ - title: item.title, - score: item.score, - })); - })() - - - map: - rank: ${{ index + 1 }} - title: ${{ item.title }} - score: ${{ item.score }} - - - limit: ${{ args.limit }} - -columns: [rank, title, score] -``` - -For public APIs (no browser): - -```yaml -strategy: public -browser: false - -pipeline: - - fetch: - url: https://api.example.com/hot.json - - select: data.items - - map: - title: ${{ item.title }} - - limit: ${{ args.limit }} -``` - -### TypeScript Adapter (programmatic) - -Create `src/clis//.ts`. It will be automatically dynamically loaded (DO NOT manually import it in `index.ts`): - -```typescript -import { cli, Strategy } from '../../registry.js'; - -cli({ - site: 'mysite', - name: 'search', - strategy: Strategy.INTERCEPT, // Or COOKIE - args: [{ name: 'query', required: true, positional: true }], - columns: ['rank', 'title', 'url'], - func: async (page, kwargs) => { - await page.goto('https://www.mysite.com/search'); - - // Inject native XHR/Fetch interceptor hook - await page.installInterceptor('/api/search'); - - // Auto scroll down to trigger lazy loading - await page.autoScroll({ times: 3, delayMs: 2000 }); - - // Retrieve intercepted JSON payloads - const requests = await page.getInterceptedRequests(); - - let results = []; - for (const req of requests) { - results.push(...req.data.items); - } - return results.map((item, i) => ({ - rank: i + 1, title: item.title, url: item.url, - })); - }, -}); -``` - -**When to use TS**: XHR interception (`page.installInterceptor`), infinite scrolling (`page.autoScroll`), cookie extraction, complex data transforms (like GraphQL unwrapping). - -## Pipeline Steps - -| Step | Description | Example | -|------|-------------|---------| -| `navigate` | Go to URL | `navigate: https://example.com` | -| `fetch` | HTTP request (browser cookies) | `fetch: { url: "...", params: { q: "..." } }` | -| `evaluate` | Run JavaScript in page | `evaluate: \| (async () => { ... })()` | -| `select` | Extract JSON path | `select: data.items` | -| `map` | Map fields | `map: { title: "${{ item.title }}" }` | -| `filter` | Filter items | `filter: item.score > 100` | -| `sort` | Sort items | `sort: { by: score, order: desc }` | -| `limit` | Cap result count | `limit: ${{ args.limit }}` | -| `intercept` | Declarative XHR capture | `intercept: { trigger: "navigate:...", capture: "api/hot" }` | -| `tap` | Store action + XHR capture | `tap: { store: "feed", action: "fetchFeeds", capture: "homefeed" }` | -| `snapshot` | Page accessibility tree | `snapshot: { interactive: true }` | -| `click` | Click element | `click: ${{ ref }}` | -| `type` | Type text | `type: { ref: "@1", text: "hello" }` | -| `wait` | Wait for time/text | `wait: 2` or `wait: { text: "loaded" }` | -| `press` | Press key | `press: Enter` | - -## Template Syntax - -```yaml -# Arguments with defaults -${{ args.query }} -${{ args.limit | default(20) }} - -# Current item (in map/filter) -${{ item.title }} -${{ item.data.nested.field }} - -# Index (0-based) -${{ index }} -${{ index + 1 }} -``` - -## 5-Tier Authentication Strategy - -| Tier | Name | Method | Example | -|------|------|--------|---------| -| 1 | `public` | No auth, Node.js fetch | Hacker News, V2EX | -| 2 | `cookie` | Browser fetch with `credentials: include` | Bilibili, Zhihu | -| 3 | `header` | Custom headers (ct0, Bearer) | Twitter GraphQL | -| 4 | `intercept` | XHR interception + store mutation | 小红书 Pinia | -| 5 | `ui` | Full UI automation (click/type/scroll) | Last resort | - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `OPENCLI_DAEMON_PORT` | 19825 | Daemon listen port | -| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) | -| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) | -| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) | -| `OPENCLI_VERBOSE` | — | Show daemon/extension logs | - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| `npx not found` | Install Node.js: `brew install node` | -| `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension | -| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML | -| Empty table data | Check if evaluate returns correct data path | -| Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs | diff --git a/docs/adapters-doc/ones.md b/docs/adapters-doc/ones.md new file mode 100644 index 00000000..c2866d75 --- /dev/null +++ b/docs/adapters-doc/ones.md @@ -0,0 +1,32 @@ +# ONES 项目管理平台(OpenCLI) + +基于官方 [ONES Project API](https://developer.ones.cn/zh-CN/docs/api/readme/),经 **Chrome + Browser Bridge** 在页面里 `fetch`(`credentials: 'include'`)。 + +## 环境变量 + +| 变量 | 必填 | 说明 | +|------|------|------| +| `ONES_BASE_URL` | 是 | 与 Chrome 中访问的 ONES 根 URL 一致 | +| `ONES_USER_ID` / `ONES_AUTH_TOKEN` | 视部署 | 若接口强制要文档中的 Header,再设置(可先只依赖浏览器登录) | +| `ONES_EMAIL` / `ONES_PHONE` / `ONES_PASSWORD` | 否 | 供 `ones login` 脚本化 | + +## 命令 + +```bash +export ONES_BASE_URL=https://your-host +# 安装扩展,Chrome 已登录 ONES + +opencli ones me +opencli ones token-info # teams column includes name(uuid), useful for tasks +opencli ones tasks --limit 20 --project +opencli ones my-tasks --limit 100 # default assignee=self +opencli ones my-tasks --mode field004 # deployments using field004 as assignee +opencli ones my-tasks --mode both # assignee OR creator +opencli ones task --team # single task (URL .../task/) +opencli ones worklog 2 --team # log hours for today +opencli ones worklog 1 --team --date 2026-03-01 # backfill +opencli ones login --email you@corp.com --password '***' # optional; stderr prints header export hints +opencli ones logout +``` + +更完整的说明见 [docs/adapters/browser/ones.md](../adapters/browser/ones.md)。 diff --git a/docs/adapters/browser/ones.md b/docs/adapters/browser/ones.md new file mode 100644 index 00000000..06a18415 --- /dev/null +++ b/docs/adapters/browser/ones.md @@ -0,0 +1,59 @@ +# ONES + +**Mode**: 🔐 Browser Bridge · **Domain**: `ones.cn` (self-hosted via `ONES_BASE_URL`) + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli ones login` | Login via Project API (`auth/login`) | +| `opencli ones me` | Current user profile (`users/me`) | +| `opencli ones token-info` | Token/user/team summary (`auth/token_info`) | +| `opencli ones tasks` | Team task list with status/project labels and hours | +| `opencli ones my-tasks` | My tasks (`assign`/`field004`/`owner`/`both`) | +| `opencli ones task` | Task detail by UUID (`team/:team/task/:id/info`) | +| `opencli ones worklog` | Log/backfill hours (GraphQL `addManhour` first, then REST fallbacks) | +| `opencli ones logout` | Logout (`auth/logout`) | + +## Usage Examples + +```bash +# Required: your ONES base URL +export ONES_BASE_URL=https://your-instance.example.com + +# Optional if your deployment requires auth headers +# export ONES_USER_ID=... +# export ONES_AUTH_TOKEN=... + +# Login/profile +opencli ones login --email you@company.com --password 'your-password' +opencli ones me +opencli ones token-info + +# Task lists +opencli ones tasks --limit 20 +opencli ones tasks --project --assign +opencli ones my-tasks --limit 100 +opencli ones my-tasks --mode both + +# Task detail +opencli ones task --team + +# Worklog: today / backfill +opencli ones worklog 2 --team +opencli ones worklog 1.5 --team --date 2026-03-23 --note "integration" + +opencli ones logout +``` + +## Prerequisites + +- Chrome running and logged into your ONES instance +- [Browser Bridge extension](/guide/browser-bridge) installed +- `ONES_BASE_URL` set to the same origin opened in Chrome + +## Notes + +- This adapter targets legacy ONES Project API deployments. +- `ONES_TEAM_UUID` can be set to omit `--team` in `tasks` / `my-tasks` / `task`. +- Hours display and input use `ONES_MANHOUR_SCALE` (default `100000`). diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 951092ce..0f151c28 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -46,6 +46,7 @@ Run `opencli list` for the live registry. | **[weixin](/adapters/browser/weixin)** | `download` | 🔐 Browser | | **[36kr](/adapters/browser/36kr)** | `news` `hot` `search` `article` | 🌐 / 🔐 | | **[producthunt](/adapters/browser/producthunt)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 | +| **[ones](/adapters/browser/ones)** | `login` `me` `token-info` `tasks` `my-tasks` `task` `worklog` `logout` | 🔐 Browser Bridge + `ONES_BASE_URL` | ## Public API Adapters diff --git a/src/clis/ones/common.ts b/src/clis/ones/common.ts new file mode 100644 index 00000000..3daad26a --- /dev/null +++ b/src/clis/ones/common.ts @@ -0,0 +1,187 @@ +/** + * ONES 旧版 Project API — 经 Browser Bridge 在已登录标签页内 fetch(携带 Cookie)。 + * 文档:https://developer.ones.cn/zh-CN/docs/api/readme/ + */ + +import type { IPage } from '../../types.js'; +import { CliError } from '../../errors.js'; + +export const API_PREFIX = '/project/api/project'; + +export function getOnesBaseUrl(): string { + const u = process.env.ONES_BASE_URL?.trim().replace(/\/+$/, ''); + if (!u) { + throw new CliError( + 'CONFIG', + 'Missing ONES_BASE_URL', + 'Set ONES_BASE_URL to your deployment origin, e.g. https://your-team.ones.cn (no trailing slash).', + ); + } + return u; +} + +export function onesApiUrl(apiPath: string): string { + const base = getOnesBaseUrl(); + const p = apiPath.replace(/^\/+/, ''); + return `${base}${API_PREFIX}/${p}`; +} + +/** 打开 ONES 根地址,确保后续 fetch 与页面同源、带上登录 Cookie */ +export async function gotoOnesHome(page: IPage): Promise { + await page.goto(getOnesBaseUrl(), { waitUntil: 'load' }); + await page.wait(2); +} + +/** + * 在页面内发起请求。默认带 credentials;若设置了 ONES_USER_ID + ONES_AUTH_TOKEN,则附加文档要求的 Header(与纯 Cookie 二选一或并存,取决于部署)。 + */ +function buildHeaders(auth: boolean, includeJsonContentType: boolean): Record { + const ref = getOnesBaseUrl(); + const out: Record = { Referer: ref }; + if (auth) { + const uid = + process.env.ONES_USER_ID?.trim() || + process.env.ONES_USER_UUID?.trim() || + process.env.Ones_User_Id?.trim(); + const tok = process.env.ONES_AUTH_TOKEN?.trim() || process.env.Ones_Auth_Token?.trim(); + if (uid && tok) { + out['Ones-User-Id'] = uid; + out['Ones-Auth-Token'] = tok; + } + } + if (includeJsonContentType) out['Content-Type'] = 'application/json'; + return out; +} + +export function summarizeOnesError(status: number, body: unknown): string { + if (body && typeof body === 'object') { + const o = body as Record; + const parts: string[] = []; + if (typeof o.type === 'string') parts.push(o.type); + if (typeof o.reason === 'string') parts.push(o.reason); + if (typeof o.errcode === 'string') parts.push(o.errcode); + if (typeof o.message === 'string') parts.push(o.message); + if (o.code !== undefined && o.code !== null) parts.push(`code=${String(o.code)}`); + if (parts.length) return parts.filter(Boolean).join(' · '); + } + return status === 401 ? 'Unauthorized' : `HTTP ${status}`; +} + +/** ONES 部分接口 HTTP 200 但 body 仍为错误(如 reason: ServerError) */ +function throwIfOnesPeekBusinessError(apiPath: string, parsed: unknown): void { + if (parsed === null || typeof parsed !== 'object') return; + const o = parsed as Record; + if (Array.isArray(o.groups)) return; + const hasErr = + (typeof o.reason === 'string' && o.reason.length > 0) || + (typeof o.errcode === 'string' && o.errcode.length > 0) || + (typeof o.type === 'string' && o.type.length > 0); + if (!hasErr) return; + const detail = summarizeOnesError(200, parsed); + throw new CliError( + 'FETCH_ERROR', + `ONES ${apiPath}: ${detail}`, + '若 query 不合法会返回 ServerError;可试 opencli ones tasks(空 must)或检查筛选器文档。响应全文可用 -v 或临时打日志。', + ); +} + +export async function onesFetchInPageWithMeta( + page: IPage, + apiPath: string, + options: { + method?: string; + body?: string | null; + auth?: boolean; + skipGoto?: boolean; + } = {}, +): Promise<{ ok: boolean; status: number; parsed: unknown }> { + if (!options.skipGoto) { + await gotoOnesHome(page); + } + + const url = onesApiUrl(apiPath); + const method = (options.method ?? 'GET').toUpperCase(); + const auth = options.auth !== false; + const body = options.body ?? null; + const includeCt = body !== null || method === 'POST' || method === 'PUT' || method === 'PATCH'; + const headers = buildHeaders(auth, includeCt); + + const urlJs = JSON.stringify(url); + const methodJs = JSON.stringify(method); + const headersJs = JSON.stringify(headers); + const bodyJs = body === null ? 'null' : JSON.stringify(body); + + const raw = await page.evaluate(` + (async () => { + const url = ${urlJs}; + const method = ${methodJs}; + const headers = ${headersJs}; + const body = ${bodyJs}; + const init = { + method, + headers: { ...headers }, + credentials: 'include', + }; + if (body !== null) init.body = body; + const res = await fetch(url, init); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + return { ok: res.ok, status: res.status, parsed }; + })() + `); + + return raw as { ok: boolean; status: number; parsed: unknown }; +} + +/** 当前操作用户 8 位 uuid(Header 或 GET users/me) */ +export async function resolveOnesUserUuid(page: IPage, opts?: { skipGoto?: boolean }): Promise { + const fromEnv = + process.env.ONES_USER_ID?.trim() || + process.env.ONES_USER_UUID?.trim() || + process.env.Ones_User_Id?.trim(); + if (fromEnv) return fromEnv; + + const data = (await onesFetchInPage(page, 'users/me', { skipGoto: opts?.skipGoto })) as Record; + const u = data.user && typeof data.user === 'object' ? (data.user as Record) : data; + if (!u || typeof u.uuid !== 'string') { + throw new CliError( + 'FETCH_ERROR', + 'Could not read current user uuid from users/me', + 'Set ONES_USER_ID or ensure Chrome is logged in; try: opencli ones me -f json', + ); + } + return String(u.uuid); +} + +export async function onesFetchInPage( + page: IPage, + apiPath: string, + options: { + method?: string; + body?: string | null; + auth?: boolean; + /** 已在 ONES 根页时设为 true,避免每条 API 都 goto+wait(显著提速) */ + skipGoto?: boolean; + } = {}, +): Promise { + const r = await onesFetchInPageWithMeta(page, apiPath, options); + if (!r.ok) { + const detail = summarizeOnesError(r.status, r.parsed); + const hint = + r.status === 401 + ? '在 Chrome 中打开 ONES 并登录;或先执行 opencli ones login 后按提示 export ONES_USER_ID / ONES_AUTH_TOKEN;并确认 ONES_BASE_URL 与浏览器地址一致。' + : '检查 ONES_BASE_URL、VPN/内网,以及实例是否仍为 Project API 路径。'; + throw new CliError('FETCH_ERROR', `ONES ${apiPath}: ${detail}`, hint); + } + + if (apiPath.includes('/filters/peek')) { + throwIfOnesPeekBusinessError(apiPath, r.parsed); + } + + return r.parsed; +} diff --git a/src/clis/ones/enrich-tasks.ts b/src/clis/ones/enrich-tasks.ts new file mode 100644 index 00000000..eefe82a2 --- /dev/null +++ b/src/clis/ones/enrich-tasks.ts @@ -0,0 +1,47 @@ +/** + * peek 列表只有轻量字段,用 batch tasks/info 补全 summary 等(ONES 文档 #7) + */ + +import type { IPage } from '../../types.js'; +import { onesFetchInPage } from './common.js'; + +const BATCH_SIZE = 40; + +export async function enrichPeekEntriesWithDetails( + page: IPage, + team: string, + entries: Record[], + skipGoto: boolean, +): Promise[]> { + const ids = [...new Set(entries.map((e) => String(e.uuid ?? '').trim()).filter(Boolean))]; + if (ids.length === 0) return entries; + + const byId = new Map>(); + + try { + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const slice = ids.slice(i, i + BATCH_SIZE); + const parsed = (await onesFetchInPage(page, `team/${team}/tasks/info`, { + method: 'POST', + body: JSON.stringify({ ids: slice }), + skipGoto, + })) as Record; + + const tasks = Array.isArray(parsed.tasks) ? (parsed.tasks as Record[]) : []; + for (const t of tasks) { + const id = String(t.uuid ?? ''); + if (id) byId.set(id, t); + } + } + } catch { + return entries; + } + + if (byId.size === 0) return entries; + + return entries.map((e) => { + const id = String(e.uuid ?? ''); + const full = id ? byId.get(id) : undefined; + return full ? { ...e, ...full } : e; + }); +} diff --git a/src/clis/ones/login.ts b/src/clis/ones/login.ts new file mode 100644 index 00000000..3b28d7f3 --- /dev/null +++ b/src/clis/ones/login.ts @@ -0,0 +1,103 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { getOnesBaseUrl, onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'login', + description: + 'ONES Project API — login via Chrome Bridge (POST auth/login); stderr prints export hints for ONES_USER_ID / TOKEN', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'email', + type: 'str', + required: false, + help: 'Account email (or set ONES_EMAIL)', + }, + { + name: 'phone', + type: 'str', + required: false, + help: 'Account phone (or set ONES_PHONE); ignored if email is set', + }, + { + name: 'password', + type: 'str', + required: false, + help: 'Password (or set ONES_PASSWORD)', + }, + ], + columns: ['uuid', 'name', 'email', 'token_preview'], + + func: async (page, kwargs) => { + const email = (kwargs.email as string | undefined)?.trim() || process.env.ONES_EMAIL?.trim(); + const phone = (kwargs.phone as string | undefined)?.trim() || process.env.ONES_PHONE?.trim(); + const password = + (kwargs.password as string | undefined) || process.env.ONES_PASSWORD || ''; + + if (!password) { + throw new CliError( + 'CONFIG', + 'Password required', + 'Pass --password or set ONES_PASSWORD for non-interactive use.', + ); + } + if (!email && !phone) { + throw new CliError( + 'CONFIG', + 'email or phone required', + 'Pass --email or --phone (or set ONES_EMAIL / ONES_PHONE).', + ); + } + + getOnesBaseUrl(); + const bodyObj: Record = { password }; + if (email) bodyObj.email = email; + else bodyObj.phone = phone!; + + const parsed = (await onesFetchInPage(page, 'auth/login', { + method: 'POST', + body: JSON.stringify(bodyObj), + auth: false, + })) as Record; + + const user = parsed.user as Record | undefined; + if (!user?.uuid || !user?.token) { + throw new CliError( + 'FETCH_ERROR', + 'ONES login response missing user.uuid or user.token', + 'Your server build may differ from documented Project API.', + ); + } + + const uuid = String(user.uuid); + const token = String(user.token); + const name = String(user.name ?? ''); + const em = String(user.email ?? ''); + + const base = getOnesBaseUrl(); + console.error( + [ + '', + '后续请求会优先使用当前 Chrome 会话 Cookie;若接口仍要求 Header,可 export:', + ` export ONES_BASE_URL=${JSON.stringify(base)}`, + ` export ONES_USER_ID=${JSON.stringify(uuid)}`, + ` export ONES_AUTH_TOKEN=${JSON.stringify(token)}`, + '', + ].join('\n'), + ); + + return [ + { + uuid, + name, + email: em, + token_preview: token.length > 12 ? `${token.slice(0, 6)}…${token.slice(-4)}` : '***', + }, + ]; + }, +}); diff --git a/src/clis/ones/logout.ts b/src/clis/ones/logout.ts new file mode 100644 index 00000000..ac754e64 --- /dev/null +++ b/src/clis/ones/logout.ts @@ -0,0 +1,19 @@ +import { cli, Strategy } from '../../registry.js'; +import { onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'logout', + description: 'ONES Project API — invalidate current token (GET auth/logout) via Chrome Bridge', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['ok', 'detail'], + + func: async (page) => { + await onesFetchInPage(page, 'auth/logout', { method: 'GET' }); + return [{ ok: 'true', detail: 'Server logout ok; clear local ONES_AUTH_TOKEN if set.' }]; + }, +}); diff --git a/src/clis/ones/me.ts b/src/clis/ones/me.ts new file mode 100644 index 00000000..c65164e4 --- /dev/null +++ b/src/clis/ones/me.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'me', + description: 'ONES Project API — current user (GET users/me) via Chrome Bridge', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['uuid', 'name', 'email', 'phone', 'status'], + + func: async (page) => { + const data = (await onesFetchInPage(page, 'users/me')) as Record; + const u = data.user && typeof data.user === 'object' ? (data.user as Record) : data; + + if (!u || typeof u.uuid !== 'string') { + throw new CliError('FETCH_ERROR', 'Unexpected users/me response', 'See raw JSON with: opencli ones me -f json'); + } + + return [ + { + uuid: String(u.uuid), + name: String(u.name ?? ''), + email: String(u.email ?? ''), + phone: String(u.phone ?? ''), + status: u.status != null ? String(u.status) : '', + }, + ]; + }, +}); diff --git a/src/clis/ones/my-tasks.ts b/src/clis/ones/my-tasks.ts new file mode 100644 index 00000000..c8dc8c2c --- /dev/null +++ b/src/clis/ones/my-tasks.ts @@ -0,0 +1,148 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { gotoOnesHome, onesFetchInPage, resolveOnesUserUuid } from './common.js'; +import { enrichPeekEntriesWithDetails } from './enrich-tasks.js'; +import { resolveTaskListLabels } from './resolve-labels.js'; +import { + defaultPeekBody, + flattenPeekGroups, + mapTaskEntry, + parsePeekLimit, +} from './task-helpers.js'; + +/** 文档示例里「负责人」常用 field004;与顶层 assign 在不同部署上二选一有效 */ +function queryAssign(userUuid: string): Record { + return { must: [{ equal: { assign: userUuid } }] }; +} + +function queryAssignField004(userUuid: string): Record { + return { must: [{ in: { 'field_values.field004': [userUuid] } }] }; +} + +function queryOwner(userUuid: string): Record { + return { must: [{ equal: { owner: userUuid } }] }; +} + +function dedupeByUuid(entries: Record[]): Record[] { + const seen = new Set(); + const out: Record[] = []; + for (const e of entries) { + const id = String(e.uuid ?? ''); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(e); + } + return out; +} + +async function peekTasks( + page: IPage, + team: string, + query: Record, + cap: number, +): Promise[]> { + const path = `team/${team}/filters/peek`; + const body = defaultPeekBody(query); + const parsed = (await onesFetchInPage(page, path, { + method: 'POST', + body: JSON.stringify(body), + skipGoto: true, + })) as Record; + return flattenPeekGroups(parsed, cap); +} + +cli({ + site: 'ones', + name: 'my-tasks', + description: + 'ONES — my work items (filters/peek + strict must query). Default: assignee=me. Use --mode if your site uses field004 for assignee.', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'team', + type: 'str', + required: false, + positional: true, + help: 'Team UUID from URL …/team//…, or set ONES_TEAM_UUID', + }, + { + name: 'limit', + type: 'int', + default: 100, + help: 'Max rows (default 100, max 500)', + }, + { + name: 'mode', + type: 'str', + default: 'assign', + choices: ['assign', 'field004', 'owner', 'both'], + help: + 'assign=负责人(顶层 assign);field004=负责人(筛选器示例里的 field004);owner=创建者;both=负责人∪创建者(两次 peek 去重)', + }, + ], + columns: ['title', 'status', 'project', 'uuid', 'updated', '工时'], + + func: async (page, kwargs) => { + const team = + (kwargs.team as string | undefined)?.trim() || + process.env.ONES_TEAM_UUID?.trim() || + process.env.ONES_TEAM_ID?.trim(); + if (!team) { + throw new CliError( + 'CONFIG', + 'team UUID required', + 'Pass team from URL …/team//… or set ONES_TEAM_UUID.', + ); + } + + const limit = parsePeekLimit(kwargs.limit, 100); + const mode = String(kwargs.mode ?? 'assign'); + + await gotoOnesHome(page); + const userUuid = await resolveOnesUserUuid(page, { skipGoto: true }); + + let entries: Record[] = []; + + if (mode === 'both') { + const cap = Math.min(500, limit * 2); + const asAssign = await peekTasks(page, team, queryAssign(userUuid), cap); + const asOwner = await peekTasks(page, team, queryOwner(userUuid), cap); + entries = dedupeByUuid([...asAssign, ...asOwner]).slice(0, limit); + } else { + const queryByMode = (): Record => { + switch (mode) { + case 'field004': + return queryAssignField004(userUuid); + case 'owner': + return queryOwner(userUuid); + case 'assign': + default: + return queryAssign(userUuid); + } + }; + + const primary = queryByMode(); + try { + entries = await peekTasks(page, team, primary, limit); + } catch (e) { + const msg = e instanceof Error ? e.message : ''; + const canFallback = + mode === 'assign' && + (msg.includes('ServerError') || msg.includes('801') || msg.includes('Params is invalid')); + if (canFallback) { + entries = await peekTasks(page, team, queryAssignField004(userUuid), limit); + } else { + throw e; + } + } + } + + const enriched = await enrichPeekEntriesWithDetails(page, team, entries, true); + const labels = await resolveTaskListLabels(page, team, enriched, true); + return enriched.map((e) => mapTaskEntry(e, labels)); + }, +}); diff --git a/src/clis/ones/resolve-labels.ts b/src/clis/ones/resolve-labels.ts new file mode 100644 index 00000000..8a660c27 --- /dev/null +++ b/src/clis/ones/resolve-labels.ts @@ -0,0 +1,80 @@ +/** + * 把 status / project 的 uuid 解析为中文名(团队级接口各查一次或按批) + */ + +import type { IPage } from '../../types.js'; +import { onesFetchInPage } from './common.js'; +import { getTaskProjectRawId } from './task-helpers.js'; + +export async function loadTaskStatusLabels( + page: IPage, + team: string, + skipGoto: boolean, +): Promise> { + const map = new Map(); + try { + const parsed = (await onesFetchInPage(page, `team/${team}/task_statuses`, { + method: 'GET', + skipGoto, + })) as Record; + const list = Array.isArray(parsed.task_statuses) + ? (parsed.task_statuses as Record[]) + : []; + for (const s of list) { + const id = String(s.uuid ?? ''); + const name = String(s.name ?? ''); + if (id && name) map.set(id, name); + } + } catch { + /* 降级为仅显示 uuid */ + } + return map; +} + +const PROJECT_INFO_CHUNK = 25; + +export async function loadProjectLabels( + page: IPage, + team: string, + projectUuids: string[], + skipGoto: boolean, +): Promise> { + const map = new Map(); + const ids = [...new Set(projectUuids.filter(Boolean))]; + if (ids.length === 0) return map; + + try { + for (let i = 0; i < ids.length; i += PROJECT_INFO_CHUNK) { + const slice = ids.slice(i, i + PROJECT_INFO_CHUNK); + const q = slice.map(encodeURIComponent).join(','); + const path = `team/${team}/projects/info?ids=${q}`; + const parsed = (await onesFetchInPage(page, path, { + method: 'GET', + skipGoto, + })) as Record; + const projects = Array.isArray(parsed.projects) ? (parsed.projects as Record[]) : []; + for (const p of projects) { + const id = String(p.uuid ?? ''); + const name = String(p.name ?? ''); + if (id && name) map.set(id, name); + } + } + } catch { + /* 降级 */ + } + return map; +} + +export async function resolveTaskListLabels( + page: IPage, + team: string, + entries: Record[], + skipGoto: boolean, +): Promise<{ statusByUuid: Map; projectByUuid: Map }> { + const projectUuids = entries.map((e) => getTaskProjectRawId(e)).filter(Boolean); + const [statusByUuid, projectByUuid] = await Promise.all([ + loadTaskStatusLabels(page, team, skipGoto), + loadProjectLabels(page, team, projectUuids, skipGoto), + ]); + return { statusByUuid, projectByUuid }; +} diff --git a/src/clis/ones/task-helpers.test.ts b/src/clis/ones/task-helpers.test.ts new file mode 100644 index 00000000..15c6ef5e --- /dev/null +++ b/src/clis/ones/task-helpers.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { parsePeekLimit } from './task-helpers.js'; + +describe('parsePeekLimit', () => { + it('returns the fallback when the input is not numeric', () => { + expect(parsePeekLimit('abc', 30)).toBe(30); + }); + + it('clamps the input into the supported range', () => { + expect(parsePeekLimit('0', 30)).toBe(30); + expect(parsePeekLimit('999', 30)).toBe(500); + expect(parsePeekLimit('42', 30)).toBe(42); + }); +}); diff --git a/src/clis/ones/task-helpers.ts b/src/clis/ones/task-helpers.ts new file mode 100644 index 00000000..afb7110c --- /dev/null +++ b/src/clis/ones/task-helpers.ts @@ -0,0 +1,214 @@ +/** + * ONES filters/peek 响应解析(tasks / my-tasks 共用) + */ + +import { CliError } from '../../errors.js'; + +/** ONES task 里 field_values 常为 [{ field_uuid, value }, ...] */ +function pickTitleFromFieldValuesArray(fv: unknown): string { + if (!Array.isArray(fv)) return ''; + for (const item of fv) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + const fu = String(row.field_uuid ?? ''); + if (!fu.startsWith('field')) continue; + const v = row.value; + if (typeof v === 'string' && v.trim()) return v.trim(); + if (Array.isArray(v) && v.length && typeof v[0] === 'string' && v[0].trim()) return v[0].trim(); + } + return ''; +} + +export function pickTaskTitle(e: Record): string { + for (const k of ['summary', 'name', 'title', 'subject']) { + const v = e[k]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + const fromArr = pickTitleFromFieldValuesArray(e.field_values); + if (fromArr) return fromArr; + const fv = e.field_values; + if (fv && typeof fv === 'object' && !Array.isArray(fv)) { + const o = fv as Record; + for (const k of ['field001', 'field002', 'field003']) { + const v = o[k]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + } + return ''; +} + +/** 表格里标题别撑爆终端 */ +export function ellipsizeCell(s: string, max = 64): string { + const t = s.trim(); + if (t.length <= max) return t; + return `${t.slice(0, max - 1)}…`; +} + +/** 辅助列:长 uuid 缩略,完整值见 -f json */ +export function briefUuid(id: string, head = 6, tail = 4): string { + if (!id) return ''; + if (id.length <= head + tail + 1) return id; + return `${id.slice(0, head)}…${id.slice(-tail)}`; +} + +export function formatStamp(v: unknown): string { + if (v == null || v === '') return ''; + const n = Number(v); + if (Number.isNaN(n)) return String(v); + const ms = n > 1e14 ? Math.floor(n / 1000) : n > 1e12 ? n : n * 1000; + try { + return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); + } catch { + return String(v); + } +} + +export function flattenPeekGroups(parsed: Record, limit: number): Record[] { + if (!Array.isArray(parsed.groups)) { + throw new CliError( + 'FETCH_ERROR', + 'Unexpected filters/peek response (missing groups)', + 'Try -f json; check team UUID and API version.', + ); + } + + const groups = parsed.groups as Record[]; + const rows: Record[] = []; + + for (const g of groups) { + const entries = Array.isArray(g.entries) ? (g.entries as Record[]) : []; + for (const e of entries) { + rows.push(e); + if (rows.length >= limit) break; + } + if (rows.length >= limit) break; + } + + return rows.slice(0, limit); +} + +function fieldArrayFirstString(fv: unknown, fieldUuid: string): string { + if (!Array.isArray(fv)) return ''; + for (const item of fv) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + if (String(row.field_uuid ?? '') !== fieldUuid) continue; + const v = row.value; + if (typeof v === 'string') return v; + if (Array.isArray(v) && v[0] != null) return String(v[0]); + } + return ''; +} + +function fvRecord(e: Record): Record | null { + const fv = e.field_values; + return fv && typeof fv === 'object' && !Array.isArray(fv) ? (fv as Record) : null; +} + +/** 工作项状态 uuid(用于查 task_statuses 得中文名) */ +export function getTaskStatusRawId(e: Record): string { + const fv = e.field_values; + const fvObj = fvRecord(e); + if (typeof e.status_uuid === 'string') return e.status_uuid; + return fieldArrayFirstString(fv, 'field016') || (fvObj ? String(fvObj.field016 ?? '') : ''); +} + +/** 项目 uuid */ +export function getTaskProjectRawId(e: Record): string { + const fv = e.field_values; + const fvObj = fvRecord(e); + if (typeof e.project_uuid === 'string') return e.project_uuid; + return fieldArrayFirstString(fv, 'field006') || (fvObj ? String(fvObj.field006 ?? '') : ''); +} + +/** + * Project API 里 assess/total/remaining_manhour 多为**定点整数**(与 Web 上「小时」不一致); + * 常见换算:raw / 1e5 ≈ 小时。若你方实例不同,可设 `ONES_MANHOUR_SCALE`(默认 100000)。 + */ +export function onesManhourScale(): number { + const raw = Number(process.env.ONES_MANHOUR_SCALE?.trim()); + if (Number.isFinite(raw) && raw > 0) return raw; + return 1e5; +} + +/** 界面/h 小数 → API 内 manhour 整数(与列表「工时」列同一刻度) */ +export function hoursToOnesManhourRaw(hours: number): number { + if (!Number.isFinite(hours) || hours <= 0) return 0; + return Math.max(1, Math.round(hours * onesManhourScale())); +} + +function formatHoursShort(hours: number): string { + if (!Number.isFinite(hours)) return ''; + const snapped = Math.round(hours * 1e6) / 1e6; + const near = Math.round(snapped); + if (Math.abs(snapped - near) < 1e-5) return `${near}h`; + const t = Math.round(snapped * 10) / 10; + return Number.isInteger(t) ? `${t}h` : `${t.toFixed(1)}h`; +} + +function formatManhourSegment(label: string, v: unknown): string | null { + if (v == null || v === '') return null; + const n = Number(v); + if (!Number.isFinite(n)) return null; + const hours = n / onesManhourScale(); + return `${label}${formatHoursShort(hours)}`; +} + +export function formatTaskManhourSummary(e: Record): string { + const parts: string[] = []; + const a = formatManhourSegment('估', e.assess_manhour); + const t = formatManhourSegment('登', e.total_manhour); + const r = formatManhourSegment('余', e.remaining_manhour); + if (a) parts.push(a); + if (t) parts.push(t); + if (r) parts.push(r); + return parts.length ? parts.join(' ') : '—'; +} + +export interface TaskLabelMaps { + statusByUuid?: Map; + projectByUuid?: Map; +} + +export function mapTaskEntry(e: Record, labels?: TaskLabelMaps): Record { + const statusId = getTaskStatusRawId(e); + const projectId = getTaskProjectRawId(e); + + const fullUuid = String(e.uuid ?? ''); + const title = ellipsizeCell(pickTaskTitle(e)); + + const briefIfLong = (s: string) => (s.length > 14 ? briefUuid(s) : s); + + const statusLabel = labels?.statusByUuid?.get(statusId) ?? briefIfLong(statusId); + const projectLabel = labels?.projectByUuid?.get(projectId) ?? briefIfLong(projectId); + + return { + title, + status: ellipsizeCell(statusLabel, 20), + project: ellipsizeCell(projectLabel, 40), + uuid: fullUuid, + updated: formatStamp(e.server_update_stamp), + 工时: ellipsizeCell(formatTaskManhourSummary(e), 36), + }; +} + +export function defaultPeekBody(query: Record): Record { + return { + with_boards: false, + boards: null, + query, + group_by: '', + sort: [{ create_time: { order: 'desc' } }], + include_subtasks: false, + include_status_uuid: true, + include_issue_type: false, + include_project_uuid: true, + is_show_derive: false, + }; +} + +export function parsePeekLimit(value: unknown, fallback: number): number { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.max(1, Math.min(500, parsed)); +} diff --git a/src/clis/ones/task.ts b/src/clis/ones/task.ts new file mode 100644 index 00000000..72a97a29 --- /dev/null +++ b/src/clis/ones/task.ts @@ -0,0 +1,79 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { onesFetchInPage } from './common.js'; +import { formatStamp } from './task-helpers.js'; + +/** + * 工作项详情 — 对应前端路由 …/team//filter/view/…/task/ + * API: GET team/:teamUUID/task/:taskUUIDOrNumber/info + * @see https://docs.ones.cn/project/open-api-doc/project/task.html + */ +cli({ + site: 'ones', + name: 'task', + description: + 'ONES — work item detail (GET team/:team/task/:id/info); id is URL segment after …/task/', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'id', + type: 'str', + required: true, + positional: true, + help: 'Work item UUID (often 16 chars) from …/task/', + }, + { + name: 'team', + type: 'str', + required: false, + help: 'Team UUID (8 chars from …/team//…), or set ONES_TEAM_UUID', + }, + ], + columns: ['uuid', 'summary', 'number', 'status_uuid', 'assign', 'owner', 'project_uuid', 'updated'], + + func: async (page, kwargs) => { + const id = String(kwargs.id ?? '').trim(); + if (!id) { + throw new CliError('CONFIG', 'task id required', 'Pass the work item uuid from the URL path …/task/'); + } + + const team = + (kwargs.team as string | undefined)?.trim() || + process.env.ONES_TEAM_UUID?.trim() || + process.env.ONES_TEAM_ID?.trim(); + if (!team) { + throw new CliError( + 'CONFIG', + 'team UUID required', + 'Use --team or set ONES_TEAM_UUID (from …/team//…).', + ); + } + + const path = `team/${team}/task/${encodeURIComponent(id)}/info`; + const data = (await onesFetchInPage(page, path, { method: 'GET' })) as Record; + + if (typeof data.uuid !== 'string') { + const hint = + typeof data.reason === 'string' + ? data.reason + : 'Use -f json to inspect response; check id length (often 16) and team.'; + throw new CliError('FETCH_ERROR', `ONES task info: ${hint}`, 'Confirm task uuid and team match the browser URL.'); + } + + return [ + { + uuid: String(data.uuid), + summary: String(data.summary ?? ''), + number: data.number != null ? String(data.number) : '', + status_uuid: String(data.status_uuid ?? ''), + assign: String(data.assign ?? ''), + owner: String(data.owner ?? ''), + project_uuid: String(data.project_uuid ?? ''), + updated: formatStamp(data.server_update_stamp), + }, + ]; + }, +}); diff --git a/src/clis/ones/tasks.ts b/src/clis/ones/tasks.ts new file mode 100644 index 00000000..0721d816 --- /dev/null +++ b/src/clis/ones/tasks.ts @@ -0,0 +1,92 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { gotoOnesHome, onesFetchInPage } from './common.js'; +import { enrichPeekEntriesWithDetails } from './enrich-tasks.js'; +import { resolveTaskListLabels } from './resolve-labels.js'; +import { defaultPeekBody, flattenPeekGroups, mapTaskEntry, parsePeekLimit } from './task-helpers.js'; + +function buildQuery(project?: string, assign?: string): Record { + const must: unknown[] = []; + if (project?.trim()) { + must.push({ in: { 'field_values.field006': [project.trim()] } }); + } + if (assign?.trim()) { + must.push({ equal: { assign: assign.trim() } }); + } + if (must.length === 0) { + return { must: [] }; + } + return { must }; +} + +cli({ + site: 'ones', + name: 'tasks', + description: + 'ONES Project API — list work items (POST team/:team/filters/peek); use token-info -f json for team uuid', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'team', + type: 'str', + required: false, + positional: true, + help: 'Team UUID (8 chars), or set ONES_TEAM_UUID', + }, + { + name: 'project', + type: 'str', + required: false, + help: 'Filter by project UUID (field006 / 所属项目)', + }, + { + name: 'assign', + type: 'str', + required: false, + help: 'Filter by assignee user UUID (负责人 assign)', + }, + { + name: 'limit', + type: 'int', + default: 30, + help: 'Max rows after flattening groups (default 30)', + }, + ], + columns: ['title', 'status', 'project', 'uuid', 'updated', '工时'], + + func: async (page, kwargs) => { + const team = + (kwargs.team as string | undefined)?.trim() || + process.env.ONES_TEAM_UUID?.trim() || + process.env.ONES_TEAM_ID?.trim(); + if (!team) { + throw new CliError( + 'CONFIG', + 'team UUID required', + 'Pass team as first argument or set ONES_TEAM_UUID (see `opencli ones token-info -f json` → teams[].uuid).', + ); + } + + const project = (kwargs.project as string | undefined)?.trim(); + const assign = (kwargs.assign as string | undefined)?.trim(); + const limit = parsePeekLimit(kwargs.limit, 30); + + await gotoOnesHome(page); + + const body = defaultPeekBody(buildQuery(project, assign)); + const path = `team/${team}/filters/peek`; + const parsed = (await onesFetchInPage(page, path, { + method: 'POST', + body: JSON.stringify(body), + skipGoto: true, + })) as Record; + + const entries = flattenPeekGroups(parsed, limit); + const enriched = await enrichPeekEntriesWithDetails(page, team, entries, true); + const labels = await resolveTaskListLabels(page, team, enriched, true); + return enriched.map((e) => mapTaskEntry(e, labels)); + }, +}); diff --git a/src/clis/ones/token-info.ts b/src/clis/ones/token-info.ts new file mode 100644 index 00000000..78642bf7 --- /dev/null +++ b/src/clis/ones/token-info.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { onesFetchInPage } from './common.js'; + +cli({ + site: 'ones', + name: 'token-info', + description: + 'ONES Project API — session detail (GET auth/token_info) via Chrome Bridge: user, teams, org', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['uuid', 'name', 'email', 'teams', 'org_name'], + + func: async (page) => { + const root = (await onesFetchInPage(page, 'auth/token_info')) as Record; + const user = root.user && typeof root.user === 'object' ? (root.user as Record) : null; + if (!user?.uuid) { + throw new CliError('FETCH_ERROR', 'Unexpected auth/token_info response', 'Try `opencli ones me -f json` or check ONES_* env vars.'); + } + + const teamRows = Array.isArray(root.teams) ? (root.teams as Record[]) : []; + const teamsHint = teamRows + .map((t) => { + const n = String(t.name ?? '').trim(); + const u = String(t.uuid ?? '').trim(); + if (n && u) return `${n} (${u})`; + return u || n; + }) + .filter(Boolean) + .join(', '); + const org = root.org && typeof root.org === 'object' ? (root.org as Record) : null; + + return [ + { + uuid: String(user.uuid), + name: String(user.name ?? ''), + email: String(user.email ?? ''), + teams: teamsHint, + org_name: org ? String(org.name ?? '') : '', + }, + ]; + }, +}); diff --git a/src/clis/ones/worklog.test.ts b/src/clis/ones/worklog.test.ts new file mode 100644 index 00000000..400b4af1 --- /dev/null +++ b/src/clis/ones/worklog.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { buildAddManhourGraphqlBody } from './worklog.js'; + +describe('buildAddManhourGraphqlBody', () => { + it('inlines the addManhour arguments so the mutation is syntactically valid', () => { + const payload = JSON.parse( + buildAddManhourGraphqlBody({ + ownerId: 'user-1', + taskId: 'task-1', + startTime: 1711411200, + rawManhour: 150000, + note: 'Backfill', + }), + ) as { query: string }; + + expect(payload.query).toContain('mutation AddManhour'); + expect(payload.query).toContain('owner: "user-1"'); + expect(payload.query).toContain('task: "task-1"'); + expect(payload.query).toContain('start_time: 1711411200'); + expect(payload.query).toContain('hours: 150000'); + expect(payload.query).not.toContain('$owner'); + expect(payload.query).not.toContain('$task'); + }); +}); diff --git a/src/clis/ones/worklog.ts b/src/clis/ones/worklog.ts new file mode 100644 index 00000000..8b8e2b39 --- /dev/null +++ b/src/clis/ones/worklog.ts @@ -0,0 +1,306 @@ +/** + * Log/backfill work hours. Project API paths vary by deployment, + * so we try common endpoints in sequence. + */ + +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import { + gotoOnesHome, + onesFetchInPageWithMeta, + resolveOnesUserUuid, + summarizeOnesError, +} from './common.js'; +import { hoursToOnesManhourRaw } from './task-helpers.js'; + +function summarizeOnesMutationBody(parsed: unknown, status: number): string | null { + if (!parsed || typeof parsed !== 'object') { + return status >= 400 ? `HTTP ${status}` : null; + } + const o = parsed as Record; + if (Array.isArray(o.errors) && o.errors.length > 0) { + const e0 = o.errors[0]; + if (e0 && typeof e0 === 'object') { + const msg = String((e0 as Record).message ?? '').trim(); + if (msg) return msg; + } + return 'graphql errors'; + } + if (o.data && typeof o.data === 'object') { + const data = o.data as Record; + if (data.addManhour && typeof data.addManhour === 'object') { + const key = String((data.addManhour as Record).key ?? '').trim(); + if (!key) return 'addManhour returned empty key'; + } + } + if (Array.isArray(o.bad_tasks) && o.bad_tasks.length > 0) { + const b = o.bad_tasks[0] as Record; + return String(b.desc ?? b.code ?? JSON.stringify(b)); + } + if (typeof o.reason === 'string' && o.reason.trim()) return o.reason.trim(); + const c = o.code; + if (c !== undefined && c !== null) { + const n = Number(c); + if (Number.isFinite(n) && n !== 200 && n !== 0) return `code=${String(c)}`; + } + const ec = o.errcode; + if (typeof ec === 'string' && ec && ec !== 'OK') return ec; + return null; +} + +function describeAttemptFailure(r: { ok: boolean; status: number; parsed: unknown }): string | null { + if (!r.ok) return summarizeOnesError(r.status, r.parsed); + return summarizeOnesMutationBody(r.parsed, r.status); +} + +function todayLocalYmd(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +function validateYmd(s: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(s); +} + +function toLocalMidnightUnixSeconds(ymd: string): number { + const d = new Date(`${ymd}T00:00:00`); + const ms = d.getTime(); + if (!Number.isFinite(ms)) return 0; + return Math.floor(ms / 1000); +} + +function pickTaskTotalManhourRaw(parsed: unknown): number | null { + if (!parsed || typeof parsed !== 'object') return null; + const o = parsed as Record; + const n = Number(o.total_manhour); + return Number.isFinite(n) ? n : null; +} + +export function buildAddManhourGraphqlBody(input: { + ownerId: string; + taskId: string; + startTime: number; + rawManhour: number; + note: string; +}): string { + const { ownerId, taskId, startTime, rawManhour, note } = input; + const description = JSON.stringify(note); + const owner = JSON.stringify(ownerId); + const task = JSON.stringify(taskId); + + return JSON.stringify({ + query: `mutation AddManhour { + addManhour( + mode: "simple" + owner: ${owner} + task: ${task} + type: "recorded" + start_time: ${startTime} + hours: ${rawManhour} + description: ${description} + customData: {} + ) { + key + } +}`, + }); +} + +cli({ + site: 'ones', + name: 'worklog', + description: + 'ONES — log work hours on a task (defaults to today; use --date to backfill; endpoint falls back by deployment).', + domain: 'ones.cn', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'task', + type: 'str', + required: true, + positional: true, + help: 'Work item UUID (usually 16 chars), from my-tasks or browser URL …/task/', + }, + { + name: 'hours', + type: 'str', + required: true, + positional: true, + help: 'Hours to log for this entry (e.g. 2 or 1.5), converted with ONES_MANHOUR_SCALE', + }, + { + name: 'team', + type: 'str', + required: false, + help: 'Team UUID from URL …/team//…, or set ONES_TEAM_UUID', + }, + { + name: 'date', + type: 'str', + required: false, + help: 'Entry date YYYY-MM-DD, defaults to today (local timezone); use for backfill', + }, + { + name: 'note', + type: 'str', + required: false, + help: 'Optional note (written to description/desc)', + }, + { + name: 'owner', + type: 'str', + required: false, + help: 'Owner user UUID (defaults to current logged-in user)', + }, + ], + columns: ['task', 'date', 'hours', 'owner', 'endpoint'], + + func: async (page, kwargs) => { + const taskId = String(kwargs.task ?? '').trim(); + if (!taskId) { + throw new CliError('CONFIG', 'task uuid required', 'Pass the work item uuid from opencli ones my-tasks or the URL.'); + } + + const team = + (kwargs.team as string | undefined)?.trim() || + process.env.ONES_TEAM_UUID?.trim() || + process.env.ONES_TEAM_ID?.trim(); + if (!team) { + throw new CliError( + 'CONFIG', + 'team UUID required', + 'Pass --team or set ONES_TEAM_UUID (from …/team//…).', + ); + } + + const hoursHuman = Number(String(kwargs.hours ?? '').replace(/,/g, '')); + if (!Number.isFinite(hoursHuman) || hoursHuman <= 0 || hoursHuman > 1000) { + throw new CliError( + 'CONFIG', + 'hours must be a positive number (hours)', + 'Example: opencli ones worklog 2 --team ', + ); + } + + const dateArg = (kwargs.date as string | undefined)?.trim(); + const dateStr = dateArg || todayLocalYmd(); + if (!validateYmd(dateStr)) { + throw new CliError('CONFIG', 'invalid --date', 'Use YYYY-MM-DD, e.g. 2026-03-24.'); + } + + const note = String(kwargs.note ?? '').trim(); + const rawManhour = hoursToOnesManhourRaw(hoursHuman); + const startTime = toLocalMidnightUnixSeconds(dateStr); + if (!startTime) { + throw new CliError('CONFIG', 'invalid date for start_time', `Could not parse date ${dateStr}.`); + } + + await gotoOnesHome(page); + + const ownerFromKw = (kwargs.owner as string | undefined)?.trim(); + const ownerId = ownerFromKw || (await resolveOnesUserUuid(page, { skipGoto: true })); + + const entry: Record = { + owner: ownerId, + manhour: rawManhour, + start_date: dateStr, + end_date: dateStr, + desc: note, + }; + const entryAlt: Record = { + owner: ownerId, + allManhour: rawManhour, + startDate: dateStr, + endDate: dateStr, + desc: note, + }; + + const enc = encodeURIComponent(taskId); + const gqlBody = buildAddManhourGraphqlBody({ + ownerId, + taskId, + startTime, + rawManhour, + note, + }); + const attempts: { path: string; body: string }[] = [ + { path: `team/${team}/items/graphql`, body: gqlBody }, + { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify(entry) }, + { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify(entryAlt) }, + { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify({ manhours: [entry] }) }, + { path: `team/${team}/task/${enc}/manhours/add`, body: JSON.stringify({ manhours: [entryAlt] }) }, + { path: `team/${team}/task/${enc}/manhour/add`, body: JSON.stringify(entry) }, + { path: `team/${team}/task/${enc}/manhour/add`, body: JSON.stringify(entryAlt) }, + { + path: `team/${team}/tasks/update3`, + body: JSON.stringify({ + tasks: [{ uuid: taskId, manhours: [entry] }], + }), + }, + ]; + + const beforeInfo = await onesFetchInPageWithMeta(page, `team/${team}/task/${enc}/info`, { + method: 'GET', + skipGoto: true, + }); + const beforeTotal = beforeInfo.ok ? pickTaskTotalManhourRaw(beforeInfo.parsed) : null; + + let lastDetail = ''; + for (const { path, body } of attempts) { + const r = await onesFetchInPageWithMeta(page, path, { + method: 'POST', + body, + skipGoto: true, + }); + const fail = describeAttemptFailure(r); + if (!fail) { + // Guard against false success: HTTP 200 but no actual manhour change. + const afterInfo = await onesFetchInPageWithMeta(page, `team/${team}/task/${enc}/info`, { + method: 'GET', + skipGoto: true, + }); + if (afterInfo.ok) { + const afterTotal = pickTaskTotalManhourRaw(afterInfo.parsed); + const changed = + beforeTotal === null + ? afterTotal !== null + : afterTotal !== null && Math.abs(afterTotal - beforeTotal) >= 1; + if (changed) { + return [ + { + task: taskId, + date: dateStr, + hours: String(hoursHuman), + owner: ownerId, + endpoint: path, + }, + ]; + } + lastDetail = `no effect (total_manhour ${String(beforeTotal)} -> ${String(afterTotal)})`; + continue; + } + // If verification read fails, return success conservatively. + return [ + { + task: taskId, + date: dateStr, + hours: String(hoursHuman), + owner: ownerId, + endpoint: path, + }, + ]; + } + lastDetail = fail; + } + + throw new CliError( + 'FETCH_ERROR', + `ONES worklog: all endpoints failed (last: ${lastDetail})`, + ); + }, +});