Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,16 @@ opencli grok ask --prompt "问题" --web # 显式 grok.com consumer web UI 路
# HuggingFace (public)
opencli hf top --limit 10 # 热门模型

# ONES project management (Browser Bridge + ONES_BASE_URL; legacy Project API, see docs/adapters/browser/ones.md)
opencli ones me # Requires ONES logged in Chrome; optional ONES_USER_ID/TOKEN for headers
opencli ones login --email you@corp.com --password '***' # If needed; stderr prints export hints
opencli ones token-info
opencli ones tasks <teamUUID> --project <optional> --limit 30
opencli ones my-tasks <teamUUID> --limit 100 # Defaults to assignee=self; if it looks too broad try --mode field004 or --mode both
opencli ones worklog <taskUUID> 2 --team <teamUUID> [--date YYYY-MM-DD] [--note '...'] # Log/backfill hours
opencli ones task <taskUUID> --team <teamUUID> # Work item detail (URL .../task/<uuid>)
opencli ones logout

# 超星学习通 (browser)
opencli chaoxing assignments # 作业列表
opencli chaoxing exams # 考试列表
Expand Down
32 changes: 32 additions & 0 deletions docs/adapters-doc/ones.md
Original file line number Diff line number Diff line change
@@ -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 <teamUUID> --limit 20 --project <optional>
opencli ones my-tasks <teamUUID> --limit 100 # default assignee=self
opencli ones my-tasks <teamUUID> --mode field004 # deployments using field004 as assignee
opencli ones my-tasks <teamUUID> --mode both # assignee OR creator
opencli ones task <taskUUID> --team <teamUUID> # single task (URL .../task/<uuid>)
opencli ones worklog <taskUUID> 2 --team <teamUUID> # log hours for today
opencli ones worklog <taskUUID> 1 --team <teamUUID> --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)。
59 changes: 59 additions & 0 deletions docs/adapters/browser/ones.md
Original file line number Diff line number Diff line change
@@ -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 <teamUUID> --limit 20
opencli ones tasks <teamUUID> --project <projectUUID> --assign <userUUID>
opencli ones my-tasks <teamUUID> --limit 100
opencli ones my-tasks <teamUUID> --mode both

# Task detail
opencli ones task <taskUUID> --team <teamUUID>

# Worklog: today / backfill
opencli ones worklog <taskUUID> 2 --team <teamUUID>
opencli ones worklog <taskUUID> 1.5 --team <teamUUID> --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`).
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Run `opencli list` for the live registry.
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
| **[ones](/adapters/browser/ones)** | `login` `me` `token-info` `tasks` `my-tasks` `task` `worklog` `logout` | 🔐 Browser Bridge + `ONES_BASE_URL` |

## Public API Adapters

Expand Down
187 changes: 187 additions & 0 deletions src/clis/ones/common.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, string> {
const ref = getOnesBaseUrl();
const out: Record<string, string> = { 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<string, unknown>;
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<string, unknown>;
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<string> {
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<string, unknown>;
const u = data.user && typeof data.user === 'object' ? (data.user as Record<string, unknown>) : 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<unknown> {
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;
}
47 changes: 47 additions & 0 deletions src/clis/ones/enrich-tasks.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[],
skipGoto: boolean,
): Promise<Record<string, unknown>[]> {
const ids = [...new Set(entries.map((e) => String(e.uuid ?? '').trim()).filter(Boolean))];
if (ids.length === 0) return entries;

const byId = new Map<string, Record<string, unknown>>();

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<string, unknown>;

const tasks = Array.isArray(parsed.tasks) ? (parsed.tasks as Record<string, unknown>[]) : [];
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;
});
}
Loading