Skip to content
Closed
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
17 changes: 0 additions & 17 deletions .github/workflows/docs.yml

This file was deleted.

20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# AGENTS.md

Contributor guidance for AI coding tools (Cursor, Codex, Aider, etc.) and humans working in this fork.

**The complete checklist lives in [`CLAUDE.md`](./CLAUDE.md)** — read it before editing `clis/*` or `extension/*` or opening a PR.

The conventions were derived from observing how the upstream maintainer (`jackwener/OpenCLI`) amends PRs before merging. Following them upfront shortens review cycles.

Quick summary of what's in `CLAUDE.md`:

1. Use typed errors from `@jackwener/opencli/errors` — never `throw new Error()`
2. Validate `--limit` / `--page` fail-fast with `readPositiveInteger`, before `page.goto`
3. Prefer imperative `func:` over declarative `pipeline:`
4. Type-check after `JSON.parse` — `"null"` and `42` are valid JSON
5. Multi-source merge: merge first, filter last
6. Replace `Array.find` with scored sort when multiple matches are possible
7. Re-check socket / instance identity after every `await`
8. Test error paths, not just happy paths
9. Docs: name every lifecycle subtype when introducing limits
10. Rebuild `cli-manifest.json` after adapter arg changes
139 changes: 139 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Contributing to GreyC/OpenCLI

This fork's PRs are eventually upstreamed to `jackwener/OpenCLI`. The checklist below captures conventions the upstream maintainer reliably enforces by amending PRs before merging (observed across PRs #1538–#1542). Front-loading them shortens review.

This file is auto-loaded by Claude Code and read by most AI coding tools (Cursor, Codex, Aider, etc.). Human contributors should read it before opening a PR.

## Before submitting a PR — checklist

### 1. Use typed errors, never `throw new Error(...)`

In `clis/*` and `src/*`, import from `@jackwener/opencli/errors`:

| Error | When |
|-------|------|
| `ArgumentError` | Invalid CLI arg (range, format, required). Throw **before** `page.goto`. |
| `AuthRequiredError` | Cookie expired / user not logged in (maps to exit 77). |
| `EmptyResultError` | Valid request, empty result set. |
| `CommandExecutionError` | Unexpected API shape, missing field, malformed payload. |

Untyped `Error` collapses to a generic `COMMAND_EXEC` exit code and removes the caller's ability to differentiate "retry" vs "ask user to log in" vs "no data".

### 2. Validate args fail-fast, before any navigation

```js
import { readPositiveInteger } from './utils.js'; // boss adapter; similar helpers exist per-site

const limit = readPositiveInteger(kwargs.limit, 'mycmd --limit', 20, 100);
const pageNum = readPositiveInteger(kwargs.page, 'mycmd --page', 1);
```

Reject bogus inputs before `page.goto` so they don't waste a browser context.

### 3. Prefer imperative `func:` over declarative `pipeline:`

Declarative `pipeline: [{navigate}, {evaluate}]` is fine for trivial scrapers but **cannot express error classification** (auth vs empty vs malformed all collapse). Use `func: async (page, kwargs) => { ... }` whenever you need to:

- Distinguish error categories
- Branch on intermediate state
- Call multiple endpoints in sequence

### 4. After `JSON.parse`, type-check the parsed value

`JSON.parse` succeeding does not mean you got an object. `"null"`, `"true"`, `42`, `"[1,2]"` are all valid JSON that break `data.code` lookups.

```js
const data = JSON.parse(text);
if (!data || typeof data !== 'object') {
throw new CommandExecutionError('API returned malformed response');
}
if (!Array.isArray(data.zpData?.messages)) {
throw new CommandExecutionError('Expected messages array');
}
```

### 5. Multi-source merge — merge first, filter last

When combining a "primary" source with a "fallback" source, **do not gate the fallback on primary's count**. Primary may pass the count check but fail downstream filtering, leaving you short.

```js
// Bad: skips fallback when primary has enough raw nodes
const combined = primary.length >= limit ? primary : [...primary, ...fallback];

// Good: always merge, let the row-level filter be the final gate
const combined = dedupe([...primary, ...fallback]);
```

### 6. `Array.find` smell — explicit scored selection

When you write `xs.find(x => ...)`, ask: "if multiple match, which is correct?"

- If answer is "any" → `find` is fine.
- If answer is "the focused one" / "the newest" / "the one with X" → write an explicit scored sort and pick `[0]`. Otherwise behaviour is order-dependent and surprising.

### 7. Long-lived sockets + async handlers — guard stale messages after every await

If a handler crosses an `await` boundary, the global state it depends on may have changed by the time it returns. Re-check identity **after every await**, not just at entry.

```js
thisWs.onmessage = async (event) => {
if (ws !== thisWs) return; // entry guard
const result = await handleCommand(...); // long await
if (ws !== thisWs) return; // re-check
safeSend(thisWs, result);
};
```

Also: `safeSend` checks `readyState === OPEN`, not "not CLOSING/CLOSED" — `CONNECTING` is also not sendable.

### 8. Tests cover error paths, not just happy paths

Every typed error you throw should have a test:

```js
await expect(command.func(page, badInput)).rejects.toBeInstanceOf(ArgumentError);
```

Use the substring-dispatch mock pattern from `clis/boss/*.test.js`:

```js
page.evaluate.mockImplementation(async (script) => {
if (script.includes('myEndpoint')) return mockPayload;
if (script.includes('document.cookie')) return 'fake-cookie';
return {};
});
```

### 9. Docs — when introducing limits, name every lifecycle subtype

If a concept has multiple lifecycles (owned vs bound, persistent vs ephemeral) and you mention a timeout/quota/limit, **specify which subtype it applies to in the same sentence**. Readers default to assuming the strictest variant applies to all.

- Bad: "Browser sessions use a 10-minute idle timeout."
- Good: "**Owned** browser sessions use a 10-minute idle timeout. **Bound** sessions have no idle timer; they stay attached until …"

### 10. After adapter changes, rebuild the manifest

```bash
npm run build-manifest
git add cli-manifest.json
```

CLI option parsing reads from the **committed** `cli-manifest.json`, not from the adapter source at runtime. If you add `--side`, `--limit`, or any new option and forget to rebuild, the new flag is silently ignored when users run the CLI.

## Verification before opening PR

```bash
npm run build-manifest # if you touched clis/<site>/*.js or args
npx vitest run clis/<site>/ # adapter tests
npx vitest run --project unit # unit tests
npm run typecheck -- --pretty false # type check
git diff --check # whitespace
```

## Related files

- `clis/boss/utils.js` — reference for adapter helpers (`bossFetch`, `readPositiveInteger`, `assertOk`, `checkAuth`)
- `src/cli.ts` — the single-file CLI definition. Browser commands at lines 666+.
- `extension/src/background.ts` — Chrome extension service worker. Workspace lease policy at lines 209–261.
- `docs/guide/browser-bridge.md` — browser session lifecycle docs (owned vs bound)
- Memory: `~/.claude/projects/-Users-gangchen-Documents-Projects-OpenCLI/memory/feedback_opencli-pr-checklist.md` (Claude Code only — same content as this file, with owner-commit SHAs for each pattern)
7 changes: 7 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3573,6 +3573,13 @@
"boss",
"geek"
]
},
{
"name": "raw",
"type": "boolean",
"default": false,
"required": false,
"help": "Return full message body including JD card payload (no 120-char truncation); extra fields appear in --fmt json/yaml"
}
],
"columns": [
Expand Down
28 changes: 20 additions & 8 deletions clis/boss/chatmsg.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,37 @@ const TYPE_MAP = {
6: '名片', 7: '语音', 8: '视频', 9: '表情',
};

function mapBossMsg(m, friend) {
function mapBossMsg(m, friend, { raw = false } = {}) {
const fromObj = m.from || {};
const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false;
return {
const row = {
from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name),
type: TYPE_MAP[m.type] || `其他(${m.type})`,
text: m.text || m.body?.text || '',
time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
};
if (raw) {
row.security_id = m.securityId || null;
row.body = m.body || null;
}
return row;
}

function mapGeekMsg(m, friend) {
function mapGeekMsg(m, friend, { raw = false } = {}) {
const fromUid = m.from && m.from.uid;
const isFromBoss = fromUid != null && String(fromUid) === String(friend.uid);
return {
const row = {
from: isFromBoss ? '对方' : '我',
type: TYPE_MAP[m.type] || `其他(${m.type})`,
text: m.text || m.body?.text || m.body?.content || m.body?.showText ||
JSON.stringify(m.body || {}).slice(0, 120),
time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
};
if (raw) {
row.security_id = m.securityId || null;
row.body = m.body || null;
}
return row;
}

async function bossChatMsg(page, kwargs, existingFriend) {
Expand All @@ -51,15 +61,15 @@ async function bossChatMsg(page, kwargs, existingFriend) {
if (messages.length === 0) {
throw new EmptyResultError('boss chatmsg', 'Boss returned no messages for this chat.');
}
return messages.map((m) => mapBossMsg(m, friend));
return messages.map((m) => mapBossMsg(m, friend, { raw: !!kwargs.raw }));
}

async function geekChatMsg(page, kwargs, encryptSystemId) {
const friend = await findGeekFriendByUid(page, kwargs.uid, { encryptSystemId });
if (!friend) throw new EmptyResultError('boss chatmsg', '未找到该聊天(geek 侧)');
if (!friend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
const messages = await fetchGeekHistoryMsg(page, friend, { page: kwargs.page });
return messages.map((m) => mapGeekMsg(m, friend));
return messages.map((m) => mapGeekMsg(m, friend, { raw: !!kwargs.raw }));
}

cli({
Expand All @@ -75,13 +85,15 @@ cli({
{ name: 'uid', required: true, positional: true, help: 'Encrypted UID (from chatlist)' },
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
{ name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' },
{ name: 'raw', type: 'boolean', default: false, help: 'Return full message body including JD card payload (no 120-char truncation); extra fields appear in --fmt json/yaml' },
],
columns: ['from', 'type', 'text', 'time'],
func: async (page, kwargs) => {
requirePage(page);
const uid = readRequiredString(kwargs.uid, 'chatmsg uid');
const pageNum = readPositiveInteger(kwargs.page, 'chatmsg --page', 1);
const normalizedKwargs = { ...kwargs, uid, page: pageNum };
const raw = !!kwargs.raw;
const normalizedKwargs = { ...kwargs, uid, page: pageNum, raw };
const side = kwargs.side || 'auto';

if (side === 'boss') {
Expand Down Expand Up @@ -112,6 +124,6 @@ cli({
if (!geekFriend) throw new EmptyResultError('boss chatmsg', 'uid 在招聘端与求职端聊天列表中均未找到');
if (!geekFriend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
const messages = await fetchGeekHistoryMsg(page, geekFriend, { page: pageNum });
return messages.map((m) => mapGeekMsg(m, geekFriend));
return messages.map((m) => mapGeekMsg(m, geekFriend, { raw }));
},
});
69 changes: 69 additions & 0 deletions clis/boss/chatmsg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,75 @@ describe('boss chatmsg', () => {
).rejects.toBeInstanceOf(CommandExecutionError);
});

describe('--raw mode (JD card exposure)', () => {
// Long content (>200 chars) sits inside body.jobDesc, not body.content,
// so the compact-mode short-circuit (m.body?.content) does not catch it
// and the JSON.stringify(...).slice(0, 120) truncation branch is exercised.
const JD_CARD_URL = 'https://www.zhipin.com/job_detail/abc-xyz-jobid-123.html?lid=test';
const JD_CARD_CONTENT = '岗位职责:负责后端架构设计与核心服务开发,包括但不限于高并发、分布式系统、数据库优化、消息队列、缓存方案、监控告警、容灾备份等关键基础设施建设。任职要求:5年以上后端开发经验,精通 Java/Go,有大型互联网公司经验者优先,熟悉云原生技术栈(K8s/Docker/Service Mesh),具备良好的沟通协作能力,能承担技术攻坚任务,并在团队中起到技术引领作用。';
const JD_CARD_FIXTURE = {
type: 99,
received: true,
time: 1704067200000,
securityId: 'jd-sec-id-xyz',
from: { uid: 67890, name: 'Boss张' },
body: {
jobDesc: {
title: '高级后端工程师',
company: '某互联网公司',
salary: '40-60K·15薪',
city: '北京',
content: JD_CARD_CONTENT,
jobId: 'abc-xyz-jobid-123',
url: JD_CARD_URL,
},
},
};

function buildJdPageMock(msg) {
return createPageMock(async (script) => {
if (script.includes('document.cookie')) return 'test-enc-sys-id';
if (script.includes('geekFilterByLabel')) {
return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
}
if (script.includes('getGeekFriendList.json')) {
return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
}
if (script.includes('geek/historyMsg')) {
return { code: 0, zpData: { messages: [msg] } };
}
return {};
});
}

it('--raw exposes full JD card url (no truncation)', async () => {
const page = buildJdPageMock(JD_CARD_FIXTURE);
const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek', raw: true });
expect(rows).toHaveLength(1);
expect(rows[0].body).toBeDefined();
expect(rows[0].body.jobDesc.url).toBe(JD_CARD_URL);
expect(rows[0].security_id).toBe('jd-sec-id-xyz');
});

it('--raw preserves long JD content (>120 chars)', async () => {
const page = buildJdPageMock(JD_CARD_FIXTURE);
const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek', raw: true });
expect(rows[0].body.jobDesc.content).toBe(JD_CARD_CONTENT);
expect(rows[0].body.jobDesc.content.length).toBeGreaterThan(120);
});

it('compact mode (no --raw) still truncates JD card body to <=120 chars and omits body/security_id', async () => {
const page = buildJdPageMock(JD_CARD_FIXTURE);
const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' });
expect(rows).toHaveLength(1);
expect(rows[0].text.length).toBeLessThanOrEqual(120);
expect(rows[0]).not.toHaveProperty('body');
expect(rows[0]).not.toHaveProperty('security_id');
// And the full URL is NOT present in the truncated text — this is the regression #13 documents.
expect(rows[0].text).not.toContain(JD_CARD_URL);
});
});

it('--side geek reports an empty history as EmptyResultError', async () => {
const page = createPageMock(async (script) => {
if (script.includes('document.cookie')) return 'test-enc-sys-id';
Expand Down
Loading