* refactor(notion): replace built-in CDP adapter with external ntn CLI
Notion has shipped an official CLI at https://ntn.dev. It uses the
public Notion API (blocks / databases / properties / comments) instead
of reverse-engineering the Desktop UI, so it survives Notion app
updates and exposes a wider command surface than the in-tree adapter
could.
Changes:
- `src/external-clis.yaml` — register `ntn` as first-class external CLI
(binary `ntn`, homepage ntn.dev, install via the shell-pipe script
on mac/linux)
- `clis/notion/` — entire directory removed (8 commands: status /
search / read / new / write / sidebar / favorites / export)
- `docs/adapters/desktop/notion.md` — removed
- `docs/.vitepress/config.mts` — drop nav entry
- `docs/adapters/index.md` — drop adapter row
- `README.md` / `README.zh-CN.md` — drop notion from feature lines,
drop adapter table row, add `ntn` to CLI hub examples
- `docs/index.md` / `docs/zh/index.md` / `docs/guide/getting-started.md`
— drop notion from electron-control feature copy
- `skills/opencli-usage/SKILL.md` — drop notion from electron list
- `cli-manifest.json` — rebuilt with --allow-removals=8
Migration for users:
`curl -fsSL https://ntn.dev | bash` (or `opencli external install ntn`)
Then use `opencli ntn <command>` in place of `opencli notion <command>`.
Rationale: the in-tree adapter was reverse-engineered against Notion
Desktop CDP and shipped only 8 commands. The official CLI gives users
the full Notion API surface and reduces our maintenance burden to zero.
Same pattern as gh / obsidian / lark-cli / tg-cli / discord-cli / wx-cli.
Verification:
- `npx tsc --noEmit` clean
- `npx vitest run --project unit` → 1091/1 skipped
- `npm run build` (with --allow-removals=8) — manifest 809 entries
- grep notion in user-facing docs (README / docs / skills) — only
descriptive mentions remain in non-blocking places (comparison /
site-recon / electron how-to / design doc), no broken adapter
references
* fix(notion): align ntn external migration
* docs(notion): clarify ntn manual install
Mirrors PR #1464 (list-tweets) and the timeline/search/tweets/likes/thread
family: spread `...extractMedia(legacy)` into the row and surface
`has_media` + `media_urls` columns. Pure parity, no behavior change for
existing callers — media keys do not collide with the original columns.
- bookmarks.js: import `extractMedia` from ./shared.js, spread into
extractBookmarkTweet row, append columns, export __test__.
- bookmark-folder.js: same change on extractFolderTweet, export
extractFolderTweet via __test__.
- bookmarks.test.js (new): baseline + photo + video + entities-only
fallback + dedup + envelope + empty-envelope (8 tests).
- bookmark-folder.test.js: update existing baseline expectation with
has_media/media_urls, add 3 new media tests (photo / mp4 / no-media).
- cli-manifest.json: regenerated; only the two `columns` entries change.
Reverse-validated: tests fail when extractMedia spread is removed.
Audits unchanged: typed-error-lint 189/189, silent-column-drop 102/103
(pre-existing main resolution noted but not consumed here).
* feat(twitter/list-tweets): include media via extractMedia (parity with timeline/search)
list-tweets was the only X recall path that dropped media. timeline.js and
search.js both call extractMedia(legacy) and emit has_media/media_urls;
list-tweets returned only text fields, so downstream consumers (e.g.
ml-scout's rate UI) couldn't render image/video thumbnails on tweets pulled
from a list timeline.
Changes:
- Import extractMedia from ./shared.js
- Spread extractMedia(legacy) into extractTimelineTweet return
- Add has_media, media_urls to columns array (--format columns parity)
- Update unit test to assert the new shape; add coverage for photo and
video extraction
* chore(manifest): rebuild cli-manifest.json for list-tweets media columns
---------
Co-authored-by: ml-scout <ml-scout@anthropic.com>
The opencli external-CLI name is the user-typed subcommand; the binary is
what gets executed. The convention everywhere else (`gh`, `docker`,
`obsidian`, `vercel`, `dws`) is `name == binary`. Three entries violated
the convention: `tg-cli` / `discord-cli` / `wx-cli` registered an
opencli name with a `-cli` suffix that does NOT exist on the binary,
forcing the awkward double-prefix `opencli discord-cli dc` instead of
`opencli discord dc`.
The README's example column already showed the desired form
(`opencli tg search`, `opencli discord recent`, `opencli wx search`) —
only the yaml registration was out of sync.
Renames in `src/external-clis.yaml`:
* `name: tg-cli` → `name: tg` (binary: `tg`)
* `name: discord-cli`→ `name: discord` (binary: `discord`)
* `name: wx-cli` → `name: wx` (binary: `wx`)
The `binary`, `homepage`, and `install` fields are unchanged — the
underlying packages (`kabi-tg-cli`, `kabi-discord-cli`, `@jackwener/wx-cli`)
keep their published names.
Other entries left as-is: `lark-cli`, `wecom-cli`, and `dws` already have
`name == binary` (their actual binaries are `lark-cli`, `wecom-cli`, `dws`).
BREAKING CHANGE: `opencli tg-cli ...`, `opencli discord-cli ...`,
`opencli wx-cli ...` no longer resolve. Use `opencli tg ...`,
`opencli discord ...`, `opencli wx ...` instead. The feature is recent
(shipped 2026-05) so impact is expected to be minimal.
* feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug
Primary: make `opencli twitter tweets` default to the logged-in user
when no username is given, so agents can pull their own posts without
needing to know their own handle. Mirrors the existing self-detection
pattern in twitter/profile and twitter/likes (AppTabBar_Profile_Link
probe on /home, then UserByScreenName lookup). Description + help
string now mention the default so agents discover it.
Consistency pass — profile/likes/following/followers: the
self-detection in these four siblings was silently broken because
page.evaluate() primitive returns come back through the CDP bridge
wrapped as `{session: 'site:twitter', data: '/<handle>'}` (same
envelope root cause as #1525). They called `.replace()` directly on
the envelope object → TypeError surfaced as AUTH_REQUIRED 'Could not
detect logged-in user', even for logged-in users. Wrap each probe
with unwrapBrowserResult so the bare href string survives. Also:
- Add an explicit page.goto('/home') + page.wait(primaryColumn)
before the probe in likes/following so the AppTabBar sidebar is
guaranteed rendered (framework pre-nav lands on bare x.com without
the sidebar mounted).
- following.js: switch its probe from the function-literal form
`() => {...}` to a template-string. Confirmed live: function-literal
silently drops primitive returns entirely — bridge returns
`{session}` with no `data` field at all, while template-string
returns `{session, data}` as expected.
Out of scope (pre-existing, flagged as follow-up): likes/following
have additional downstream evaluate paths (userId/GraphQL fetch) that
still drop or envelope their results; they return [] or
'Could not find user' even after this PR. Same daemon-side bug class
as #1525.
Live-verified:
opencli twitter tweets --limit 2 → own tweets (@jakevin7)
opencli twitter profile → own profile
Tests 227/227, audits typed-error-lint 189 + silent-column-drop 103
unchanged, manifest stable at 816 entries.
* fix(twitter): validate self-detected handles
* fix(twitter): unwrap downstream self evaluate results
* fix(twitter): unwrap page.evaluate primitive returns in lists/list-tweets/following
The opencli >=1.7.x browser bridge wraps page.evaluate's primitive return
values as { session, data: <value> }. Adapters that destructure .data
inline (e.g. data.queryId, data.viewer) keep working because the wrapper
spreads object-typed responses to the top level, but ones that consume
the return value as a bare string broke:
- twitter list-tweets: the dynamically resolved queryId (a string) became
{session, data:"..."}. Interpolating that into the GraphQL URL produced
/i/api/graphql/[object Object]/ListLatestTweetsTimeline, giving "HTTP
400: queryId may have expired".
- twitter lists: same on ListsManagementPageTimeline queryId.
- twitter following: same shape bug on the href read from the profile
link, producing "TypeError: href.replace is not a function" when no
--user is given.
Add a small unwrap() helper at each call site so primitive returns are
extracted from the wrapper before use. Object-typed GraphQL responses
are left as-is since they rely on spread semantics.
* fix(twitter): rewrite list-add to use ListAddMember GraphQL mutation
In 2026-05 X replaced the "Add/remove from Lists" modal dialog with a
full-page route (/i/lists/add_member). The previous UI flow no longer
works:
Save button not found in dialog (X expected text Save/Done).
Dialog structure may have changed.
The mutation that the dialog used to fire (ListAddMember) is still the
right primitive — and the surrounding adapter already calls X GraphQL
APIs directly to resolve userId and verify member_count. Drop the UI
flow entirely and call ListAddMember directly via fetch in the page
context.
Wins:
- Works again on current X UI (verified 2026-05-12 on x.com).
- ~10x faster: no goto-profile + click-caret + scroll-dialog round trips.
- One less moving piece — no dependency on Chrome extension's nativeClick
for this command.
Implementation notes:
- LIST_ADD_MEMBER_QUERY_ID is a 2026-05 fallback; resolveTwitterQueryId
does live lookup from the loaded client-web bundle, matching the
pattern already used elsewhere in the twitter clis.
- X's ListAddMember response routinely contains a non-fatal partial
decode error on default_banner_media_results (code 214, Validation /
BadRequestError) alongside a fully populated data.list. We treat the
call as failed only when data.list / member_count is missing, and
ignore decode-flavored errors confined to banner fields.
- Same opencli >=1.7.x { session, data } primitive-wrap behavior that
the previous commit addressed applies here: userId from the
UserByScreenName call needs unwrap before being interpolated into
the mutation body, otherwise X parses "[object Object]" as user_id
and returns "strconv.ParseInt ... invalid syntax".
Verified flows:
- noop (already a member) → status: noop, member_count unchanged.
- new add (e.g. @AnthropicAI on a fresh list) → status: success,
member_count incremented.
Trade-off: rejection signals (e.g. X declining to add @deepseek_ai)
look indistinguishable from noop at the response level, since X returns
HTTP 200 with member_count unchanged. Documented in the success message.
* fix(twitter): integrate list media and harden list-add
---------
Co-authored-by: wangyan <wy@wang-yan-Air.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
* feat(zhihu): add answer-detail to fetch a single answer's full content
The existing `zhihu answer` adapter is a write (post an answer); the
listing `zhihu question` truncates each answer's body to 200 chars.
There was no way to fetch one specific answer's full content by id.
New read adapter `zhihu answer-detail`:
- Accepts a bare numeric answer id, a typed target `answer:<qid>:<aid>`,
or a full Zhihu answer URL (the form you paste from a browser).
- Calls `/api/v4/answers/<aid>?include=content,voteup_count,...,question`
inside the cookie-bearing page context (Strategy.COOKIE).
- Returns a single row with id / author / votes / comments /
question_id / question_title / url / created_at / updated_at /
content. The content column is the full stripped answer body by
default — no silent truncation. `--max-content N` is an opt-in user
cap (mirroring the wikipedia `page` flag), and `--max-content 0`
(the default) means "no cap, full content".
Important precision note: Zhihu answer ids since 2024 routinely
exceed `Number.MAX_SAFE_INTEGER` (the test fixture uses the real id
`1937205528846655537`). `data.id` is round-tripped through browser
`JSON.parse` and would round to `1937205528846655500`, so the adapter
deliberately ignores `data.id` for the canonical row id and anchors
it to the already-validated input string instead. A regression test
locks this contract in by mocking `data.id = 0` and asserting the row
still carries the parsed input id.
Typed errors: bad input → INVALID_INPUT; 401/403 → AuthRequiredError;
other HTTP / null → FETCH_ERROR. No silent fallbacks, no sentinel
strings.
Live-verified against the example URL — fetched 5547 votes / 165
comments / 1937205528846655537-end-to-end. 16 unit tests, audits
unchanged (typed-error-lint 189/189, silent-column-drop 103/103),
manifest 816→817.
* fix(zhihu): tighten answer-detail contracts
* fix(google-scholar/search): wrap evaluate return to fix serialization
Same issue as google/search: page.evaluate() serializes JS arrays as
plain objects across the CDP boundary, causing Array.isArray() to
return false. The adapter silently returned [] instead of results.
Also replace fixed page.wait(3) with selector-based wait for
.gs_r.gs_or.gs_scl with a 3s fallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(google-scholar): type search evaluate payload
* chore: rerun google scholar search checks
---------
Co-authored-by: cxiao <chuda.xiao@wuerzburg-dynamics.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
* fix(xiaohongshu): parseLikes should handle 2.1w / 1.5万 / 1.2k shortforms
Xiaohongshu renders top-popular comment like-counts as shortened
strings like '2.1w' / '1.1万' / '1.2k' once they exceed ~10 000.
The previous parseLikes only matched bare digits via /^\d+$/ and
silently returned 0 for any shortform, which inverted the sort
order: the highest-liked comments (often 10k+) ranked last while
mid-tier comments with plain numeric counts (e.g. 7569) appeared
on top.
Repro on any popular xiaohongshu thread (>10 000 likes on a top
comment): with --format json the most-upvoted parent rows show
"likes": 0.
This patch keeps the original fast path for plain integers and
adds a single regex for the well-known shortform suffixes:
- w / 万 -> *10000
- k / 千 -> *1000
- trailing '+' tolerated (e.g. '999+')
- unknown shapes still fall back to 0 (no behavior change)
Note: parseLikes runs inside the IIFE injected via page.evaluate(),
so the existing comments.test.js mock harness (which stubs
evaluate's return value directly) does not exercise it. A future
refactor that exports parseLikes for direct testing would be a
separate change.
Affects both top-level comments and 楼中楼 sub-replies (same
helper).
* fix(xiaohongshu): parse comment like shortforms safely
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
* chore: drop util.styleText to support Node v20+
util.styleText was added in Node v21.7.0 / v20.12.0. v21.0.0-v21.6.x and
v20.0.0-v20.11.x throw `SyntaxError: ... styleText` at startup because the
import resolves before any user code runs (a real user reported this on
v21.2.0).
OpenCLI is primarily agent-facing — terminal colors are noise to consumers,
and the [OK] / [WARN] / [FAIL] / ℹ / ⚠ / ✖ markers we already write carry
the semantic info that colors only repeated. Strip styleText entirely from
logger / output / doctor / tui / update-check / cli / download/progress /
commands/daemon and clean up the resulting awkward `${'literal'}` template
fragments. engines.node now reads ">=20.0.0".
This removes the Node-version coupling that A/B fixes would only have
papered over.
* fix(runtime): truly support Node v20+ by aligning guard + undici
Follow-up to the styleText removal: declaring engines.node >=20.0.0 is
not enough on its own. Two coupled barriers remained:
- src/runtime-detect.ts: MIN_SUPPORTED_NODE_MAJOR = 21 explicitly
rejected v20 at startup
- undici@^8.0.2 declares engines.node >=22.19.0; Node 20/21 crash on
webidl.util.markAsUncloneable before any user code runs
Lower the guard to 20 and downgrade undici to ^6.25.0 (engines >=18.17,
retains Agent / EnvHttpProxyAgent / fetch / Dispatcher). Smoke-tested
--help / doctor / list on Node v20.0.0, v21.2.0, v22.22.2. 213/213
targeted unit tests pass.
* feat(zhihu): paginate question answers and recommendations
* fix(zhihu): drop Math.min limit clamp and 'unknown' sentinel
Two audit-driven fixes on top of feat/zhihu-pagination-recommend:
1. question.js: replace `Math.min(answerLimit, 20)` with a named
constant `ZHIHU_PAGE_SIZE = 20`. The Zhihu API caps `limit` at 20
per request anyway, and the pagination loop already trims to the
user-requested `answerLimit` via `answers.length >= answerLimit`,
so the Math.min silent-clamp was both unnecessary and tripped the
silent-clamp audit. Updates the existing unit test to expect the
API-max page size in the fetch URL with an explanatory comment.
2. recommend.js: rebuild the dedup key without the `'unknown'`
sentinel. The old form `\`\${target.type || 'unknown'}:\${target.id}\``
collapsed distinct typed items into the same bucket whenever
`target.type` was missing, and tripped the silent-sentinel audit.
New form: prefer `type:targetId`, fall back to `__feed:item.id`,
and when neither id is available keep the row but skip dedup
(surfacing potentially-duplicate items beats silently dropping
them).
Audits unchanged (typed-error-lint 189/189, silent-column-drop
103/103). All 88 zhihu tests pass.
---------
Co-authored-by: lihaidong <lihaidong@kingsoft.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
Issue #1506 reports `opencli xiaohongshu search` returning `[]` even though
the page visibly has results. Trace evidence: xhs ships a render variant
where each note card is a bare `<section>` (no `note-item` class), so
the three `section.note-item` selectors in this file all match zero
elements.
Three call sites in the shared search IIFEs now use the same defensive
selector strategy: try the legacy `section.note-item` class first, then
fall back to any `<section>` that wraps a `/search_result/...` or
`/explore/...` link. The change is in the xiaohongshu file so the
rednote adapter (which imports `buildSearchExtractJs` and
`buildScrollUntilJs` from here) picks it up automatically.
Extraction-side title selector also gets a fallback: when no
`.title` / `.note-title` element matches, read the first `<span>`
inside the search-result link, which is where the bare-section render
puts the caption per the trace.
## Verification
`npx vitest run --project adapter clis/xiaohongshu/`: 105/105 green
(existing test suite unchanged, passes on both legacy and fallback paths).
Live verify on rednote (same code path, account-safe):
```
$ opencli rednote search "美食" --limit 3 -f json
[ {rank:1, title:"在朋友家吃过一次..."}, {rank:2, title:"我的15💰晚餐..."}, {rank:3, title:"干净饮食🫛..."} ]
```
Legacy `section.note-item` path is exercised here (rednote still renders
the class) and returns identical row shape to before the fix, confirming
no regression on the working path.
Live verify on xiaohongshu cannot be performed here (no logged-in xhs
session on the test machine; xhs account-ban risk per the project's
operational guidance). The fix is structural: the new `<section>` shape
the issue reporter traced is reachable through the fallback, and the
existing test fixture keeps the legacy path green.
`npx tsc --noEmit` clean. `npm run build` 815 manifest entries unchanged
shape. `silent-column-drop` / `typed-error-lint` baselines unchanged.
Closes#1506
Refs #1500
* feat(reddit/read): add --expand-more via /api/morechildren + 7-kind discriminated union
PR B of the rdt-cli parity follow-up (after PR #1491, see #1481 thread).
Closes the second-largest gap: Reddit's "[+N more replies]" stubs were
opaque markers in the comment tree. With --expand-more, the adapter
follows them by POST-ing the t1 ids to /api/morechildren.json, then
re-threads the returned things back into the tree by parent_id before
walking it.
New args:
- `--expand-more` (bool, default false) — turn on stub expansion.
- `--expand-rounds <N>` (int, default 2, range [1, 5]) — Reddit returns
fresh "more" stubs at the expansion depth boundary, so up to N rounds
are run. Strictly validated via `parseExpandRounds` — out-of-range
raises ArgumentError BEFORE `page.goto`, no silent clamp.
Boy-Scout: the in-browser script now returns a 7-kind discriminated
union instead of a flat row array (matching the PR #1428 / #1491
sediment). Each kind maps 1:1 to a typed error on the Node side:
- `inaccessible` → EmptyResultError
401/403/404 on /comments/<id>.json (post-specific access, not
session-level auth — applies the PR #1491 review-side sediment
"inaccessible-resource vs session-auth").
- `auth` → AuthRequiredError
401/403 on /api/morechildren (expand-write endpoints often demand
a logged-in session even when the read endpoint is anonymous).
- `http` → CommandExecutionError
- `malformed` → CommandExecutionError
200 with unexpected envelope shape — schema drift, not empty.
- `parser-drift` → CommandExecutionError
tree had t1 entries but the walker produced no rows (PR #1491
review-side sediment "post-construction 0 rows + pre-walk
non-empty = parser drift, not legitimate empty").
- `expand-failed`→ CommandExecutionError
/api/morechildren returned a non-empty json.errors array.
- `ok` → returns rows[].
Intermediate keys (kind / detail / httpStatus / where / rows /
expandMeta) deliberately avoid the declared columns (type / author /
score / text) per the PR #1329 silent-column-drop sediment.
Tests:
clis/reddit/read.test.js — 11 tests
- Adapter shape (browser / siteSession / columns / args)
- --expand-more / --expand-rounds present with correct types/defaults
- parseExpandRounds default / range / non-integer rejection
- Pre-navigation validation (bad --expand-rounds doesn't reach goto)
- kind=ok happy path (POST + L0 rows)
- 6-kind error → typed error mapping
- Unknown envelope shape → CommandExecutionError
- Evaluate script embeds expandMore/expandRounds/sort/limit literals
- Evaluate script contains /api/morechildren POST scaffolding
- Evaluate script never names declared columns as intermediate keys
Full reddit suite 48/48; full project 3402/3402.
Audits: typed-error-lint 189/189 (0 new), silent-column-drop 103/103
(0 new). Manifest 815 → 815 (existing read entry gets 2 new args).
Existing --limit / --depth / --replies / --max-length keep their
original Math.max-style behaviour (grandfathered in the baseline);
only the new --expand-rounds flag fails fast per the typed-errors
standard.
Refs: https://github.com/jackwener/rdt-cli (browse.read --expand-more)
* fix(reddit): preserve expanded comment tree order
* fix(reddit): fail on partial morechildren expansion
* fix(twitter): repair search and tweets readback
* fix(twitter): prefer baked operation features when bundle parse returns empty
The bundle parser in resolveTwitterOperationMetadata locates the queryId via
`queryId:"..."` inside a ~2500-char snippet around the operationName marker,
then independently extracts `featureSwitches:[...]` and `fieldToggles:[...]`
via separate regexes. When minification rearranges the snippet (or the
snippet window truncates before the array), either regex can miss while
queryId still resolves; keysToFlags(undefined) then returns {}.
sanitizeTwitterOperationMetadata previously accepted any object as
features / fieldToggles, including {}. Twitter's GraphQL endpoint rejects
SearchTimeline / UserTweets requests with empty features (HTTP 400),
surfacing a misleading "queryId may have expired" error — the queryId is
fresh; only the feature flags are missing.
Guard against this by deferring to the baked fallback whenever the resolved
map is empty. Adds a JSDOM-free unit test that, reverse-validated, fails on
the un-fixed code with the exact silent-fallback shape.
Refs PR #1512
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
page.evaluate() serializes JS arrays as plain objects, causing
Array.isArray() to return false and the adapter to throw NOT_FOUND
even when results exist. Wrap the return value in {items: results}
and extract via wrapper.items to avoid the type check issue.
Co-authored-by: cxiao <chuda.xiao@wuerzburg-dynamics.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Per @WAWQAQ direction (DM): trim PR-time CI to fast-feedback only.
adapter-test (~30-60s) is the next-largest PR wait after e2e-headed
(which #1521 just removed). Adapter authors typically run focused tests
locally before pushing (`npm run test:adapter`); CI duplication adds
queue latency without catching new classes of bugs.
PR-time CI surface now:
- typecheck / unit (~1 min)
- lint gates (typed-error / silent-column-drop)
- build × 3 platforms
Adapter test guards (still strict):
- push to main / dev
- nightly cron
- workflow_dispatch (manual when an adapter-heavy PR really wants the
signal before merge)
Same gate as smoke-test (`if: github.event_name == 'push' || schedule
|| workflow_dispatch`) for consistency.
Per-PR e2e-headed Chrome was the dominant PR-time wait (~10-15 min on
two platforms) and on fork PRs blocks behind maintainer approval, while
the actually-blocking failures it caught in the last 30 days were all
e2e-test migrations missed by the authoring PR (#1461 / #1505 workspace
->session) rather than real regressions the unit/typecheck tier missed.
PR feedback path is now:
- typecheck / unit / lint / adapter / build ← `pull_request` (ci.yml)
- extension typecheck / build ← `pull_request` (build-extension.yml)
- docs build ← `pull_request` (doc-check.yml)
- security audit ← `pull_request` (security.yml)
E2E-headed Chrome guards:
- push to main / dev (watched paths)
- push v* tag (release)
- nightly cron 08:00 UTC (added: catches Chrome version drift / flake
drift even when no commits touch watched paths)
- workflow_dispatch (manual when a PR really wants e2e signal)
smoke-test was already gated on `schedule || workflow_dispatch` only
(ci.yml), so no change needed there.
`pageScopedResult()` in extension/src/background.ts was spreading the
lease's session into the result `data` for every page-scoped command. For
the `exec` action — which routes user JavaScript through page.evaluate()
— this contaminated arbitrary user-JS returns:
* Array / primitive returns came back as `{ session, data: <value> }`
envelopes. Adapters that did `Array.isArray(result)` got `false` and
treated the page as having no rows. Visible repro:
`opencli google search ...` and `opencli xiaohongshu search ...` —
Chrome rendered results correctly but adapters extracted an empty array
(reported in #1518 from the Browser Bridge v1.0.12 envelope).
* Plain-object returns had an extra `session` key spliced in, silently
overwriting any user `session` field with the lease's value.
Fix in the extension layer instead of compensating client-side:
`pageScopedResult` now returns `{ id, ok, data, page }` — the same form
it had before #1461 added the workspace→session refactor. Client-side
unwrapping is no longer needed and the original PR #1518 `Page.evaluate`
heuristic is dropped (it only covered the array path and would have
missed the plain-object path).
Two adapter improvements kept from the original PR:
* `clis/google/search.js` — wait for `#rso a h3` (with a 5s timeout)
before extracting. On Chrome 148 / Linux Wayland the DOM can settle
before SERP anchors are populated, so the existing fixed `wait 2`
could return empty even with the envelope fix.
* `clis/xiaohongshu/search.js` — extract initially visible cards before
scrolling, then merge post-scroll rows by URL. Xiaohongshu's
virtualized masonry can evict the initial note cards from the DOM
after scroll, causing extraction to return [] even though the
browser had rendered results correctly.
Extension version bumped to 1.0.14.
Repro environment (from #1518):
* OpenCLI 1.7.18
* Browser Bridge extension 1.0.12 → 1.0.14
* Chrome 148.0.7778.96
* Linux Wayland, Node 22.22.1
Tests: extension/src/background.test.ts navigate same-url assertion
updated to no longer expect `session` in `data`. Three Page.evaluate
unwrap test cases removed.
* fix(xueqiu/kline,earnings-date): format dates in Asia/Shanghai instead of UTC (#1465)
`xueqiu/kline` and `xueqiu/earnings-date` formatted bar timestamps with
`new Date(ts).toISOString().split('T')[0]`. That string is the UTC
calendar date, always one day earlier than the date xueqiu shows in its
UI (which is Beijing-aligned for every market). Issue #1465 reports
"5月10日跑的,5月8号的k线没有" because the May 8 China trading-day bar
was labeled 2026-05-07. Same off-by-one was present in `earnings-date.js`.
Routes both call sites through a new `formatChinaDate(ts)` helper in
`clis/xueqiu/utils.js` built on `toLocaleDateString('en-CA', { timeZone:
'Asia/Shanghai' })`. Verified live against SZ300136 and AAPL: both now
match the dates shown on xueqiu.com.
Tests: `clis/xueqiu/utils.test.js` (new) pins the Asia/Shanghai semantic
with 4 cases (China midnight, late-evening, 16:00 UTC day boundary, and
nullish input). `npx vitest run --project adapter clis/xueqiu/` 49/49,
`npx tsc --noEmit` clean, `npm run build` 815 entries unchanged shape.
Closes#1465
* fix(xueqiu): stabilize China date formatting
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
`OPENCLI_KEEP_TAB` was a debugging shortcut, not a config dimension. It
let users override `--keep-tab` globally via the shell environment,
which contradicts the per-command lifecycle model: `siteSession:'persistent'`
already pins persistent site tabs as a hard adapter-metadata constraint,
and `--keep-tab true|false` covers the ad-hoc override case. The env
just leaked process state across every browser command in the shell.
Changes:
- src/execution.ts: `resolveKeepTab()` drops the
`normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)`
fallback. `--keep-tab` is now the single user override.
- src/execution.test.ts: two regression tests rewritten to use the
`executeCommand(cmd, {}, false, { keepTab: 'true' })` signature
instead of the env. Logic and assertions unchanged.
- README.md / README.zh-CN.md / skills/opencli-usage/SKILL.md:
drop the env table row. `--keep-tab` documentation stays.
- CHANGELOG.md: BREAKING entry under Unreleased.
Note: the 1.7.15 CHANGELOG entry still references the env historically;
that's intentional, historical entries are not retroactively edited.
Verification:
- npx tsc --noEmit pass
- npx vitest run --project unit --project extension → 1144/1145 pass
(1 unrelated skip)
- typed-error-lint baseline 189
- silent-column-drop baseline 103
* refactor(browser): replace --session flag with <sessionname> positional
The `--session <name>` flag was semantically required but syntactically
optional, which is an anti-pattern. Required + flag is a contradiction:
flag form implies "optional", required is a runtime patch on top. Session
is OpenCLI's "operation target" identifier — the natural form for that is
a positional argument, like `docker exec <container> <cmd>` or
`git checkout <branch>`.
New surface:
opencli browser <sessionname> open https://x.com
opencli browser <sessionname> click 12
opencli browser <sessionname> bind
opencli browser <sessionname> unbind
Commander 14 cannot natively combine a parent positional with subcommand
dispatch — the parent's positional is shadowed by subcommand matching. To
bridge that, main.ts now pre-processes argv: when the token after `browser`
is non-flag and not a known subcommand name, it is treated as the
sessionname and rewritten to the internal `--session <name>` flag form
before commander parses it. Help text on the `browser` command is
overridden via `.usage('<sessionname> <command> [options]')` so users see
the positional form.
Reserved subcommand names (33) are listed in cli-argv-preprocess.ts and
tested for parity with cli.ts subcommand registrations. If a future
subcommand is added, the test fails loudly.
Synced surfaces:
- README.md / README.zh-CN.md — all examples
- docs/guide/browser-bridge.md (+ zh)
- skills/opencli-browser/SKILL.md (bind/unbind, examples, table)
- skills/opencli-usage/SKILL.md
- tests/e2e/browser-tabs.test.ts
- CHANGELOG.md (Unreleased BREAKING)
The internal `--session` flag and the unit tests calling
`program.parseAsync(['...', 'browser', '--session', 'foo', ...])` are
preserved as a stable internal API: tests bypass main.ts pre-processing
and exercise commander directly. The pre-processor has its own targeted
test file (cli-argv-preprocess.test.ts, 10 tests, all green).
Verification:
- npx tsc --noEmit — pass
- npx vitest run --project unit — 1073/1074 pass (1 unrelated skip)
- npx vitest run --project extension — 61/61 pass
- npm run check:typed-error-lint — baseline 189
- npm run check:silent-column-drop — baseline 103
* fix(cli-argv): only rewrite when `browser` is the root command
The preprocessor was looping through every argv slot and would mis-rewrite
occurrences of the literal word `browser` deeper in argv (e.g. `opencli
adapter init browser/x` or arg values containing `browser`).
Now the preprocessor walks past leading root flags + their values to
identify the root command token, and only acts when that token is
`browser`. The full set of root value-consuming flags
(`ROOT_VALUE_FLAGS`) is documented inline and kept in sync with the
`program.option()` calls in cli.ts.
Adds regression tests:
- `opencli adapter init browser x` not rewritten
- URL/path values containing `browser` not rewritten
- `list browser state` (different root command) not rewritten
- `--profile work browser foo state` correctly identifies `foo` as
sessionname (not as --profile's value)
- `--profile=work` long-form-with-equals consumes one slot only
- boolean flags (`-v`) don't consume the next value
12/12 preprocessor tests pass.
* fix(cli-argv): hide --session flag, fail-fast on retired form, rename to <session>
Three blockers in #1505 review:
1. `--session` flag was still visible in `opencli browser --help` and could
be used as a public entrance, contradicting "positional only" UX.
Fix: switch from `.requiredOption()` to `.addOption(new Option(...).hideHelp())`.
The flag is preserved as an internal API for the daemon protocol and direct
`program.parseAsync` callers (tests), but is no longer documented or
surfaced in structured help.
2. `opencli browser --session foo state` still succeeded. Now the argv
preprocessor throws `BrowserSessionArgvError` when root `browser` is
followed by `--session`, and main.ts catches it and exits with a
user-facing usage error pointing to the positional form.
3. Missing-session error message exposed the internal flag:
`required option '--session <name>' not specified`. Now `getBrowserSession()`
in the action body throws `<session> is a required positional argument:
opencli browser <session> <command>`, and commander no longer guards the
hidden option.
Also (per @WAWQAQ) rename placeholder `<sessionname>` -> `<session>` everywhere
user-facing — shorter, matches CLI convention. The help text "<session> is a
required positional: pass the name of the browser session..." carries the
"name" semantics in description, not in the placeholder itself.
Sync surfaces:
- src/cli.ts — usage line, addOption with hideHelp, descriptions
- src/cli-argv-preprocess.ts — throw on --session form
- src/cli-argv-preprocess.test.ts — refusal test for old form
- src/cli.test.ts — assertions updated for hidden option + new error path
- src/help.ts — read `_usage` private field to respect `.usage()` override
(commander's `.usage()` getter returns auto-generated form if not set,
which would otherwise pollute every namespace's usage string)
- src/main.ts — catch BrowserSessionArgvError, stderr + exit
- README.md / README.zh-CN.md
- docs/guide/browser-bridge.md / docs/zh/guide/browser-bridge.md
- skills/opencli-browser/SKILL.md / skills/opencli-usage/SKILL.md
- CHANGELOG.md
Manual smoke tests (against built dist):
- `opencli browser --help` shows `Usage: opencli browser <session> <command> [options]`
- `opencli browser --help` Options block does NOT show `--session`
- `opencli browser --session foo state` → friendly error, no commander stacktrace
- `opencli browser state` → `<session> is a required positional argument: opencli browser <session> <command>`
- `opencli browser foo state` → parses correctly
* fix: inject <session> into subcommand help paths and drop stale sessions ref
Two follow-up blockers from #1505 review:
1. Subcommand help and structured help still rendered the command path
without the parent's positional. `opencli browser foo state --help`
showed `Usage: opencli browser state [options]`, which would lead
users (and agents reading structured help) to think
`opencli browser state` was a valid invocation. Now:
- `commanderPath()` injects an ancestor's leading-positional placeholder
(extracted from its `.usage()` override) between the ancestor's name
and the next path segment when building paths upward.
- `commandPathFromRoot()` strips placeholder segments (e.g. `<session>`)
from the relative `name` field so agents can still address subcommands
by their leaf name; placeholders remain in the `command` / `usage`
display paths.
- `program.configureHelp({ commandUsage: ... })` is applied recursively
to every descendant of `browser`, because commander does NOT inherit
`configureHelp` into subcommands.
Result:
opencli browser <session> click --help
-> Usage: opencli browser <session> click [target] [options]
Daemon, plugin, adapter, profile namespaces (no `.usage()` override)
are unaffected.
2. `skills/opencli-browser/SKILL.md` still referenced
`opencli browser sessions`, which was removed in #1470. Replaced the
sentence with the underlying invariant ("Bound sessions have no
OpenCLI idle-close timer; the binding lasts until `unbind`, tab close,
window close, or daemon restart") without mentioning the deleted
command.
Tests:
- cli.test.ts: structured help expectations updated to include
`<session>` in command/usage paths (3 tests)
- cli-argv-preprocess.test.ts: 12 tests still green
- 1136/1137 unit+extension green (1 unrelated skip)
- typed-error-lint baseline 189
- silent-column-drop baseline 103
* feat(reddit): add whoami, home, subreddit-info read commands
Closes gap against jackwener/rdt-cli — three commands the existing 17 reddit
adapters were missing:
- `reddit whoami` — show the currently logged-in identity (fields:
Username, ID, Post / Comment / Total Karma, Account Created, Gold, Mod,
Verified Email, Has Mail, Inbox Count). Probes `/api/me.json` with
two-pronged auth detection (401/403 OR `data.name` missing on 200 —
Reddit returns 200 with an empty body for stale anon sessions, see PR
#1428).
- `reddit home` — personalized Best feed (`/best.json`). Distinct from
the public `frontpage`/`r/all` command: enforces login via the same
two-pronged auth check rather than silently degrading to the
unauthenticated default feed. `--limit` accepts [1, 100] — out-of-range
raises `ArgumentError` before navigation, no silent clamp.
- `reddit subreddit-info` — subreddit metadata (Name, Title, Subscribers,
Active Now, NSFW, Type, Description, Created, URL) from
`/r/<X>/about.json`. Banned / private / quarantined / 404 subreddits
raise `EmptyResultError` so the output table never holds a silent
sentinel row.
All three use Strategy.COOKIE + siteSession:'persistent' matching the
existing reddit adapters, validate args upfront before `page.goto`, and
use the 5-kind discriminated-union pattern (kind: auth/http/missing/
exception/ok) from PR #1428 to map page.evaluate results to typed errors
on the Node side. Intermediate object keys deliberately avoid the
declared columns (`field`/`value`/`rank`/etc.) per the silent-column-drop
audit sediment from PR #1329.
Tests: 28 new (whoami 6, home 9, subreddit-info 13); full reddit suite
38/38. Audits: typed-error-lint 189/189 (0 new), silent-column-drop
103/103 (0 new). Manifest 812 → 815.
Refs: https://github.com/jackwener/rdt-cli
* fix(reddit): tighten new read command failure contracts
* fix(reddit): treat inaccessible subreddit info as empty
* chore(scripts): auto-refresh dist/ before build-manifest
`build-manifest.ts` is invoked via tsx so its own imports go to TS source,
but the adapter `.js` files it loads import `@jackwener/opencli/registry`
through package exports, which resolves to `dist/src/registry-api.js`.
When `dist/` is stale relative to `src/` (e.g. a contributor edits
`src/registry.ts` and runs only `npm run build-manifest` instead of the
full `npm run build`), the stale dist drops fields like `siteSession`
from the rebuilt manifest. CI catches the resulting diff via the
"cli-manifest.json is up-to-date" gate, but locally it surfaces as
mysterious unrelated diff lines for adapter files the contributor never
touched.
Add an npm pre-script that runs `tsc --build` (incremental, ~0.6s when
warm) so `npm run build-manifest` is safe to use directly. `npm run build`
is unchanged — it still does the full `clean-dist + tsc + copy-yaml +
build-manifest` sequence, and `prebuild-manifest` will be a no-op there
since TS is already compiled by the time it runs.
Verified:
- `rm -rf dist && npm run build-manifest` now restores dist via the
pre-hook and produces a 0-line diff against committed manifest
- `npm run build` still produces the same clean output
* fix(scripts): force manifest dist refresh
* fix(scripts): avoid duplicate manifest compile
* feat(ctrip): add hotel-search + flight browser-mode commands
Closes#1481.
Two new browser-mode commands on top of the existing public `search` /
`hotel-suggest` pair:
- `ctrip hotel-search <city> --checkin --checkout [--limit]` reads
`window.__NEXT_DATA__.props.pageProps.initListData.hotelList` on
`hotels.ctrip.com/hotels/list`. SSR-rendered first page ships ~13
entries; the server ignores `&pageSize=N` so limit caps at 30 with
default 10. AuthRequiredError surfaces when Ctrip redirects to the
captcha gate.
- `ctrip flight <from> <to> --date [--limit]` searches one-way flights on
`flights.ctrip.com/online/list/oneway-…`. The post-load XHR is not
currently captured by the daemon network buffer (per the known
daemon_capture_pipeline_bug_2026_05_07 in agent memory), so rows are
pulled from `.flight-list > span > div` cards via a position-anchored
innerText parser. A generic `buildScrollUntilJs(selector, target)`
helper mirrors the PR #1487 xiaohongshu scroll-until pattern with the
selector parameterised. Round-trip + airline filters are out of scope
for v1.
All argument validation (IATA / ISO date / city ID / limit range) fires
upfront before any `page.goto`, per the PR #1387 boundary standard. No
silent clamps, no sentinel rows: rows missing required fields are
dropped, and end-state checks raise `ArgumentError` /
`AuthRequiredError` / `EmptyResultError` as appropriate. The new
`mapHotelRow` / `pickHotelMapCoords` / `buildFlightExtractJs` /
`buildScrollUntilJs` helpers live in `clis/ctrip/utils.js` alongside the
existing suggest helpers.
Docs at `docs/adapters/browser/ctrip.md` now distinguish the public
suggest commands from the browser-mode commands and document each
command's columns + caveats.
Verified:
- 61/61 vitest tests in `clis/ctrip/ctrip.test.js` (including JSDOM
exercises of `buildFlightExtractJs` and full `mapHotelRow` shape parity)
- `check:typed-error-lint` 189/189 (0 new)
- `check:silent-column-drop` 103/103 (0 new)
- `build-manifest` clean — 812 entries total (was 810)
* fix(ctrip): harden browser search failure contracts
* fix(ctrip): tighten browser empty-vs-parser failures
* docs(skill/adapter-author): warn aria-label / placeholder / title is locale-dependent
aria-label changes with the browser's UI language (chrome://settings/languages).
A button labelled `aria-label="Submit"` in English Chrome becomes
`aria-label="提交"` in Chinese Chrome, so CSS selectors hardcoded to one
locale silently match zero elements — `notEmpty` / `types` never fire because
the adapter just returns 0 rows.
First-principles framing in adapter-template:
- Split DOM attributes into "locale-stable identifiers" (id / class /
data-testid / data-* / role) vs "locale-dependent text" (aria-label /
title / placeholder / alt / textContent)
- Primary selectors must use locale-stable identifiers; locale-dependent
text is a last-resort tiebreaker
- When a site (e.g. ChatGPT web) only exposes aria-label, link the existing
`clis/chatgpt/utils.js` fallback-list pattern (en + zh-CN + stable
fallback at the front)
Explicitly document why we are NOT building a `find --i18n "zh:提交"` flag
(over-engineering: same indirection as a fallback list plus a translation
dictionary to maintain) and why we are NOT locking Chrome's locale at launch
(opencli doesn't launch Chrome — it connects to the user's running browser
via CDP, so forcing en-US would break users who intentionally run Chinese UI).
Adds pitfall #11 to success-rate-pitfalls.md for the agent-facing checklist.
Closes#1474
* docs(skill): tighten locale selector guidance
* fix(xiaohongshu+rednote): scroll until enough rows are rendered instead of fixed 2x autoScroll
Both search adapters previously called `page.autoScroll({ times: 2 })` which
hard-capped extraction at ~13 notes (xiaohongshu lazy-loads ~5-7 notes per
scroll round) regardless of `--limit`. Reported in #1471: `--limit 40` still
only returned 13 results.
Replace with a dynamic `buildScrollUntilJs(targetCount, maxScrolls=15)`
helper that:
- counts visible `section.note-item` rows (excluding `.query-note-item`
related-search rows)
- breaks early when count >= target
- breaks early after 2 consecutive scrolls add no new rows (DOM plateaued,
feed exhausted)
- hard caps at 15 iterations to bound runtime
Exported from xiaohongshu and reused by rednote (same DOM shape) instead of
duplicating the IIFE.
Fixes#1471
* fix(xiaohongshu): tighten search scroll boundary
* fix(doubao/ask): restore Assistant turn detection after 2026-05 DOM refactor
Doubao reworked message-item wrappers and dropped all `receive-message` /
`bg-g-receive-msg-bubble` markers from assistant turns. The legacy 6
`itemSelectors` (`item-kDun2N`, `union_message`, `message-block-container`,
`data-message-id`, `bg-g-send-msg-bubble`, `bg-g-receive-msg-bubble`) match 0
elements on the new DOM, so `getTurnsScript` returned [] and `getDoubaoTurns`
fell through to the whole-page transcript scraper. Assistant text came back as
sidebar labels + history titles + adjacent conversation snippets concatenated
with the real reply — silent SELECTOR failure (no thrown error).
Two minimal changes in `clis/doubao/utils.js` `getTurnsScript`:
1. `itemSelectors`: prepend `[class*="inner-item-"]` and `[class*="top-item-"]`
— the new 2026-05 wrappers. Outer wins via existing ancestor-keep dedup
below, so we get one root per turn (not one per nested chunk).
2. `getRole`: add a third fallback branch — if the root matches
`inner-item-*` / `top-item-*`, contains `.flow-markdown-body`, and has NO
`bg-g-send-msg-bubble` marker (User detection still works), treat it as
Assistant. `.flow-markdown-body` is already in `messageTextSelectors`, so
text extraction kicks in unchanged.
Test added asserting both new wrappers and the `.flow-markdown-body` assistant
fallback are present in the generated script.
Fixes#1478
* test(doubao): cover refactored assistant turns
* fix(youtube): request srv3 format for caption URLs (#1420)
YouTube may return empty responses when caption URLs lack an explicit format
parameter. This adds fmt=srv3 (standard YouTube XML caption format) to the
caption URL when no fmt parameter is already present, with a fallback to the
original URL if srv3 also returns empty.
Also adds HTTP status checking before reading the response body, preventing
silent failures on non-200 responses.
Fixes#1420
* fix(youtube): preserve caption fetch failures
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
* feat(reddit): add reply command for replying to comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(reddit/reply): replace silent-sentinel rows with typed errors
reply.js originally mirror-copied comment.js's failure pattern: returning
[{ status: 'failed', message: 'HTTP 403' }] on auth/HTTP/Reddit errors and
relying on the caller to inspect the row instead of throwing. That's the
'silent-sentinel' anti-pattern from typed-errors.md — failures should
surface as typed errors so an agent can actually branch on them.
Round 21 lesson (f) — "grandfathered-not-exempt + helper-refactor boundary
is new" — applies: comment.js / upvote.js / save.js can stay grandfathered,
but a brand-new file does not inherit that exemption.
Changes:
- Throw AuthRequiredError when /api/me.json or /api/comment returns 401/403,
or when /api/me.json returns 200 but data.name is missing (stale anon
session — empty modhash alone isn't a strong enough signal).
- Throw CommandExecutionError for non-2xx HTTP and for non-empty
data.json.errors (e.g. RATELIMIT, NO_TEXT, TOO_OLD).
- Drop the over-defensive `if (!page) throw ...` — registry guarantees a
page object when browser:true.
- Intermediate result object uses `kind` discriminator + `detail` /
`httpStatus` / `where` keys that don't overlap with columns
['status','message'], so the silent-column-drop audit stays quiet
(per PR #1329 sediment).
Verified:
- npx tsc --noEmit clean
- node scripts/check-typed-error-lint.mjs → 189/189, 0 new
- node scripts/check-silent-column-drop.mjs → 103/103, 0 new
- npx vitest run clis/reddit src/convention-audit → 11/11 pass
- node ./dist/src/main.js validate → 0 errors
Success path is unchanged: still returns
[{ status: 'success', message: 'Reply posted on t1_<id>' }].
* fix(reddit): harden reply command contract
* fix(reddit): reject suffixed reply urls
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
* feat(rednote): add rednote.com adapter mirroring xiaohongshu read commands (#1136)
Implements rednote.com support as discussed in issue #1136. The mainland
xiaohongshu adapter stays in place; international users redirected to
www.rednote.com now have a CLI without a copy-pasted adapter.
Issue #1136 documents that xiaohongshu and rednote share DOM selectors,
URL paths, API paths, response schema, cookies, and the xsec_token auth
mechanism. The only material differences:
Layer xiaohongshu rednote
Web host www.xiaohongshu.com www.rednote.com
API host edith.xiaohongshu.com webapi.rednote.com
Security host fe-static.xhscdn.com as.rednote.com
Cookie root .xiaohongshu.com .rednote.com
Search gate Inline text Full-screen modal + text
## Architecture (minimal)
`clis/xiaohongshu/*` keep all selector / regex / extraction logic. Each
command file is touched minimally to export the IIFE or pipeline so the
sibling adapter can reuse it:
search.js + export const buildSearchExtractJs(webHost)
+ export const command = cli({...})
note.js + export const NOTE_EXTRACT_JS
+ export const command = cli({...})
comments.js + export function buildCommentsExtractJs(withReplies)
+ export parseCommentLimit
+ export const command = cli({...})
download.js + export function buildDownloadExtractJs(noteId)
(CDN allowlist now includes rednote alongside xhscdn)
+ export const command = cli({...})
user.js + export const USER_SNAPSHOT_JS
+ export const command = cli({...})
feed.js + export function buildFeedPipeline(webHost)
+ export const command = cli({...})
notifications.js + export function buildNotificationsPipeline(webHost)
+ export const command = cli({...})
note-helpers.js buildNoteUrl now accepts `cookieRoot` + `signedUrlHint`
options (defaults preserved so xhs callers and tests
are unchanged)
user-helpers.js buildXhsNoteUrl / extractXhsUserNotes accept an
optional `webHost` argument (default xhs)
The `export const command = cli({...})` pattern matches twitter/lists.js
and clis/discord-app/*; without it the build-manifest scanner attributes
xhs's command to whichever rednote sibling triggered the transitive
import first.
## clis/rednote/ — thin shims
Each rednote command file imports the relevant builder / constant from
its xiaohongshu sibling and calls `cli()` with the rednote host triple.
No selectors, regexes, or extraction logic are duplicated.
search.js imports buildSearchExtractJs + noteIdToDate
declares its own WAIT_FOR_CONTENT_JS (modal + text
login-gate variants — the one xhs behaviour that
genuinely differs)
note.js imports NOTE_EXTRACT_JS + buildNoteUrl + parseNoteId
comments.js imports buildCommentsExtractJs + parseCommentLimit
+ buildNoteUrl + parseNoteId
download.js imports buildDownloadExtractJs + buildNoteUrl + parseNoteId
user.js imports USER_SNAPSHOT_JS + extractXhsUserNotes
+ normalizeXhsUserId
## Scope (initial)
Ships the five commands verified live against the user's logged-in
rednote.com session: search / note / comments / user / download.
`feed` and `notifications` are intentionally left out. Both rely on
intercepting the xiaohongshu Pinia store at the `homefeed` / `you`
capture pattern; live verification on rednote returns `tap → dict
(error)` for the feed step, so shipping them would surface a broken
contract. The mainland xiaohongshu commands continue to work. Adding
the rednote-side feed / notifications is straightforward follow-up
work once someone with rednote access maps the network surface.
Creator-center commands (publish, creator-*) have no rednote
counterpart and stay xiaohongshu-only, per the reporter's note in #1136.
## Verification
- clis/xiaohongshu/ + clis/rednote/: 103/103 tests green
- npx tsc --noEmit: clean
- npm run build: 807 manifest entries (xhs 13 + rednote 5 + everything
else preserved)
- silent-column-drop / typed-error-lint: 103 / 189 baseline entries,
no new violations
- Live verify against the user's rednote.com session:
rednote search "travel" --limit 1 → real note row
rednote note <signed-url> → 7 field/value rows
rednote comments <signed-url> --limit 3 → 3 top-level rows
rednote user 5b21f6564eacab3b38f05c39 --limit 2 → 2 profile notes
Spaced 15–30s between runs per the xhs/rednote rate-limit guidance;
no write commands invoked. Regression check: xiaohongshu/feed on
the existing mainland session still returns the standard 6-field
rows after the refactor.
Closes#1136
* fix(rednote): tighten adapter failure boundaries
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
Doctor's job is browser-bridge health diagnosis. The `--no-live` flag
let users skip the connectivity probe (= the core diagnostic), and
`--sessions` listed automation sessions (a separate concern not part of
health). Both flags accreted features that violated the command's
first-principles purpose.
Cleanup chain (removing dead code surfaced by the flag removal):
- `--no-live` / `--sessions` flags removed from `opencli doctor`
- `DoctorOptions.live` / `DoctorOptions.sessions` removed
- `DoctorReport.sessions` removed
- `[SKIP] Connectivity` render branch removed (always-live now)
- `listSessions()` removed (only consumer was doctor)
- `'sessions'` action removed from daemon-client protocol type
- `BrowserSessionInfo` type removed (no remaining consumers)
- extension `handleSessions` action handler removed (1.0.12)
- extension test "reports sessions per session" removed
- `OPENCLI_BROWSER_IDLE_TIMEOUT` test rewired to 'cookies' action
Verification:
- root typecheck + extension typecheck pass
- doctor.test.ts 17/17 pass
- extension/background.test.ts 49/49 pass
- typed-error-lint 189/189 baseline
- silent-column-drop 103/103 baseline
- build + extension build green