Commit Graph

1177 Commits

Author SHA1 Message Date
jackwener 8d98d5652e fix(cli): show external CLI package aliases 2026-05-14 16:25:34 +08:00
jakevin 29c135b656 refactor(notion): replace built-in CDP adapter with external ntn CLI (#1559)
* 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
2026-05-14 16:12:17 +08:00
jakevin 7edf53783f fix(daemon): report unknown browser command results (#1558) 2026-05-14 14:30:13 +08:00
jakevin af7b94152f feat(twitter): add extractMedia parity to bookmarks + bookmark-folder (#1555)
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).
2026-05-14 14:24:31 +08:00
Ocean 6b26aedd56 feat(twitter/list-tweets): include media via extractMedia (parity with timeline/search) (#1464)
* 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>
2026-05-14 14:01:34 +08:00
jakevin 68b18cdbcd fix(extension): coalesce daemon websocket connects (#1554) 2026-05-14 13:57:38 +08:00
J.Chen cddc84776c docs(browser): clarify named session lifecycle (#1542)
* docs(browser): clarify named session lifecycle

* docs(browser): clarify owned versus bound sessions

---------

Co-authored-by: Jeff Chen <jeff@adtiming.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-14 13:38:41 +08:00
J.Chen 4f5fcd9acb fix(extension): keep active daemon websocket
Keep stale Browser Bridge WebSocket events from clobbering the active daemon connection.\n\nCo-authored-by: Jeff Chen <jeff@adtiming.com>
2026-05-14 13:38:05 +08:00
jakevin 40b2f75098 feat(external)!: drop -cli suffix from tg/discord/wx subcommand names (#1544)
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.
2026-05-14 04:13:39 +08:00
jakevin feab24f76c chore(release): 1.7.19 (#1543) v1.7.19 2026-05-14 02:33:56 +08:00
jakevin 8ef7e903b8 feat(twitter): default tweets to logged-in user + fix sibling envelope-unwrap silent bug (#1531)
* 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
2026-05-13 22:51:48 +08:00
ppop123 7c5bafd49b fix(twitter): repair list-add / list-tweets / lists / following after 2026-05 changes (#1503)
* 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>
2026-05-13 22:35:57 +08:00
jakevin f66996a148 fix(twitter): raise cursor pagination guard
Fix hidden pagination ceilings across Twitter cursor-pagination adapters.\n\nCo-authored-by: Mingming Lou <1109198+lmmsoft@users.noreply.github.com>
2026-05-13 22:14:05 +08:00
jakevin 4fac911425 feat(zhihu): add answer-detail to fetch a single answer's full content (#1528)
* 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
2026-05-13 21:47:37 +08:00
xcd_git b52da639a3 fix(google-scholar/search): wrap evaluate return to fix serialization (#1525)
* 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>
2026-05-13 19:07:39 +08:00
Joseph赛博阿隆 b1dca04ddd fix(xiaohongshu): parseLikes should handle 2.1w / 1.5万 / 1.2k shortforms (#1504)
* 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>
2026-05-13 18:47:20 +08:00
jakevin f481585ba1 chore: drop util.styleText to support Node v20+ (#1524)
* 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.
2026-05-13 18:33:21 +08:00
lenovobenben 723f2b9147 feat(zhihu): paginate question answers and recommendations (#1517)
* 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>
2026-05-13 18:28:44 +08:00
Benjamin Liu 2babed84e9 fix(xiaohongshu+rednote/search): fall back to href-based note cards when section.note-item class is dropped (#1506) (#1507)
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
2026-05-13 18:24:55 +08:00
陈家名 a6ca53c7cf fix: clamp download progress percentages (#1520)
* fix: clamp download progress percentages

* test(download): cover unknown progress total

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-13 18:24:26 +08:00
jakevin c3912d8e5c feat(reddit/read): --expand-more via /api/morechildren + 7-kind typed errors (#1492)
* 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
2026-05-13 18:13:11 +08:00
darthjaja 67599ea67c fix(twitter): repair search and tweets readback (#1512)
* 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>
2026-05-13 18:11:41 +08:00
darthjaja f321a6096d fix(twitter): make reply submission robust (#1511) 2026-05-13 18:10:19 +08:00
xcd_git 59ebf551f0 fix(google/search): wrap evaluate return value in object to fix serialization (#1523)
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>
2026-05-13 18:05:03 +08:00
jakevin 04a57029b3 ci(adapter-test): gate adapter-test off pull_request trigger (#1522)
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.
2026-05-13 17:55:23 +08:00
jakevin fd438c2109 ci(e2e): drop e2e-headed from pull_request trigger (#1521)
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.
2026-05-13 17:45:41 +08:00
Xiaohan Li 1eac8e0776 fix(browser): drop session injection from extension exec results (#1518)
`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.
2026-05-13 17:45:07 +08:00
Benjamin Liu 6af4db2ab5 fix(xueqiu/kline,earnings-date): format dates in Asia/Shanghai instead of UTC (#1498)
* 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>
2026-05-13 17:40:28 +08:00
jakevin 5127211ec1 refactor(extension): remove lease key session backdoor (#1510) 2026-05-12 22:43:30 +08:00
jakevin 587750cad3 refactor(env): remove OPENCLI_KEEP_TAB (#1509)
`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
2026-05-12 22:02:17 +08:00
jakevin a77e05847f feat(browser): add function form page evaluate (#1508) 2026-05-12 21:48:44 +08:00
jakevin 0e168d570e refactor(browser): replace --session flag with <sessionname> positional (#1505)
* 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
2026-05-12 20:44:29 +08:00
jakevin fa9b38cd92 feat(reddit): add whoami, home, subreddit-info read commands (#1491)
* 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
2026-05-12 04:10:44 +08:00
jakevin 93bc374437 chore(scripts): auto-refresh dist/ before build-manifest (#1490)
* 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
2026-05-12 04:00:24 +08:00
jakevin eb59b7444d feat(ctrip): add hotel-search + flight browser-mode commands (#1481) (#1489)
* 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
2026-05-12 03:43:32 +08:00
jakevin 43d0722264 docs(skill/adapter-author): aria-label / placeholder / title are locale-dependent (#1474) (#1488)
* 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
2026-05-12 03:13:53 +08:00
jakevin 7df9b80dea fix(xiaohongshu+rednote): scroll until enough rows for --limit > 13 (#1471) (#1487)
* 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
2026-05-12 02:59:15 +08:00
jakevin 23e1161ffd chore(release): 1.7.18 (#1486) v1.7.18 2026-05-12 02:50:24 +08:00
jakevin dccf9d00e9 fix(doctor): pass session to connectivity probe (#1485)
* fix(doctor): pass session to connectivity probe

* fix(doctor): isolate probe session name

* fix(cli): mark browser session as required
2026-05-12 02:49:21 +08:00
jakevin b476d2364f fix(doubao/ask): restore Assistant detection after 2026-05 DOM refactor (#1484)
* 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
2026-05-12 02:46:53 +08:00
Kagura 6d84009ee8 fix(youtube): request srv3 format for caption URLs (#1420) (#1422)
* 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>
2026-05-12 02:44:21 +08:00
Gaurav Saxena 150551be8c feat(reddit): add reply command for replying to comments (#1428)
* 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>
2026-05-12 02:33:09 +08:00
Benjamin Liu 64ac362a40 feat(rednote): add rednote.com adapter mirroring xiaohongshu read commands (#1136) (#1475)
* 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>
2026-05-12 02:32:25 +08:00
jakevin c1af68b909 chore(release): 1.7.17 (#1483) v1.7.17 2026-05-12 02:24:16 +08:00
E2ern1ty b262d8ffd5 feat(chatgpt): support local image uploads (#1476)
* feat(chatgpt): support local image uploads

* chore: refresh cli manifest

* fix(chatgpt): harden image upload flow

* fix(chatgpt): validate image uploads before navigation

* fix(chatgpt): keep send fallback click in sync

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-11 17:52:16 +08:00
jakevin 987d9cba48 refactor(doctor): drop --no-live and --sessions flags + dead protocol (#1470)
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
2026-05-11 13:10:06 +08:00
jakevin 467fdd0b62 refactor(adapter): rename site browser reuse to persistent sessions (#1462) 2026-05-11 04:56:34 +08:00
jakevin 9c06e84c89 refactor(browser): replace workspaces with sessions (#1461) 2026-05-11 04:26:51 +08:00
jakevin b56bebdd7a chore(release): 1.7.16 (#1460) v1.7.16 2026-05-11 03:27:06 +08:00
jakevin 1d2e606498 perf(chatgpt): replace fixed-sleep waits with selector-based readiness (D3) (#1456)
Continues the wait→event sweep started in #1449 (deepseek) / #1452 (claude). Same
3-bucket classification across the chatgpt adapter:

CONVERT (5)
- utils.js ensureOnChatGPT/startNewChat: 2s settle → wait({selector: composer, 8s})
- utils.js getConversationList: openSidebar 1.5s + fallback goto 2.5s → selector
- detail.js: post-/c/<id> goto 2s → wait({selector: message bubble, 10s})

DELETE (6)
- ask/send/read.js: standalone 2s settle after ensureOnChatGPT/startNewChat
  (those helpers now wait for composer internally — settle is redundant)
- utils.js sendChatGPTMessage: 0.5s post-closeBtn + 1.5s pre-composer-focus
- utils.js getConversationList: 2s settle after ensureOnChatGPT (helper waits
  for composer; we re-check sidebar selector independently)

KEEP (6)
- utils.js sendChatGPTMessage: ProseMirror React debounce ticks
- utils.js waitForChatGPTResponse: streaming response polling cadence

Verification:
- npx vitest run clis/chatgpt → 20/20 (4 files)
- Full vitest → 3379 pass / 1 skip (2 errors are the unrelated daemon EADDRINUSE
  flake also seen on #1449/#1452/#1454)
- tsc --noEmit clean / typed-error-lint 189 / silent-column-drop 103 unchanged
- npm run build → 802 manifest entries

Diff: +50 / -13 across 5 files (ask.js, detail.js, read.js, send.js, utils.js).
No typed-error harmonization needed — chatgpt's existing helpers already gate
through ensureChatGPTLogin (AuthRequiredError) and ensureChatGPTComposer
(CommandExecutionError) correctly.
2026-05-11 03:22:39 +08:00