turndown pulls in @mixmark-io/domino, which publishes its full test suite
to npm: 959 files / 7 MB, ~94% of the package's file count. Upstream has
been unmaintained since 2024 (mixmark-io/domino#2), so remove the test
directory in our postinstall instead. Runs before the CI early-return so
packaged app bundles (OpenCLIApp stages node_modules into Resources/)
shrink as well. Best-effort: resolution failure or a missing dir never
fails the install.
Co-authored-by: exe.dev user <exedev@koala-fife.exe.xyz>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(twitter): add device-follow command for /i/timeline notification stream (#1628)
Closes#1628. Adds the twitter device-follow command, which reads the
curated tweet list aggregated under a bell-icon "new posts from @userA
and N others" notification. Direct GET /i/timeline redirects to /home,
so the data is only reachable via the legacy v1.1 REST endpoint
/i/api/2/notifications/device_follow.json , none of the existing
twitter commands cover this stream:
- twitter timeline home for-you / following feed (different endpoint)
- twitter notifications the notification list itself, not aggregated
tweets inside any one notification
- twitter search search-based, can't reproduce the aggregation
Endpoint discovery + field-mapping originally proposed by @traddo in
#1628; this PR upstreams a clean implementation that:
- Strategy.COOKIE + ct0 from CDP cookie jar + the public web bearer
token from clis/twitter/utils.js (same auth path as twitter timeline)
- Hits /i/api/2/notifications/device_follow.json directly via
page.evaluate fetch on the x.com origin so SameSite=Lax cookies are
preserved
- Joins each entry.content.item.content.tweet.id to
globalObjects.tweets[id] and resolves the author via
globalObjects.users[tweet.user_id_str]
- Returns the canonical twitter row columns (id, author, text, likes,
retweets, replies, views, created_at, url), matching twitter timeline
minus has_media / media_urls / card / quoted_tweet which the legacy
v1.1 endpoint does not surface
- Sets views: null rather than a 0 sentinel; the legacy endpoint does
not return view counts even with include_ext_views=true, and the
GraphQL TweetResultByRestId round-trip per tweet was judged too
expensive for a list command (typed-errors §3: no scalar sentinels
that lie about real engagement)
- parseLimit enforces strict 1-200 integer validation with no silent
clamping; the only baseline addition is the silent-sentinel on the
"unknown" author fallback, which matches the exact precedent in
twitter/timeline.js:76 that is already baselined
Tests: 17 unit tests in device-follow.test.js cover parseLimit strict
validation, URL parameter shape, entry/tweet join, user-resolution
fallback, dedup via the seen set, empty-stream shape, the canonical
column registration, AuthRequiredError on missing ct0, and
CommandExecutionError on non-2xx fetch.
Live verified the endpoint shape end-to-end against the logged-in
session: HTTP 200 with the expected
{globalObjects: {tweets, users}, timeline: {id: 'tweet_notifications',
instructions: [{addEntries: {entries: []}}]}} envelope. The tester
account has no bell-notification follows enabled, so entries is empty,
but the shape and auth path are confirmed against the documented
spec.
* fix(twitter): harden device-follow typed boundaries
* fix(twitter): fail fast on device-follow drift
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
* feat(reddit): subscribed command + expose id/created_utc/selftext on listing commands
Adds `opencli reddit subscribed` to list the user's subscribed subreddits,
mirroring `saved.js`'s cookie auth + AuthRequiredError pattern. Auto-paginates
via `/subreddits/mine/subscriptions.json` (max 1000 subs, default 100).
Also extends the JSON output of `popular` / `search` / `subreddit` with
`id`, `created_utc`, `selftext` (and `author` on popular) — the table
view stays clean (columns: unchanged), but `--format json` now surfaces
fields needed for downstream content-recommendation tooling that filters
by post age, dedupes by post id, or uses self-post bodies for embeddings.
Tests: 4 new vitest cases for subscribed.js (happy / auth fail / HTTP /
--limit truncation). All existing reddit tests still pass.
Note on cli-manifest.json diff: the rebuild on fork/main drops 13 entries
whose source files import lowercase `selectorError` from
`@jackwener/opencli/errors` (the actual export is `SelectorError` —
casing bug pre-existing in fork/main). Not introduced by this PR.
* fix(reddit): harden subscribed listing contract
* fix(reddit): require subreddit identity for subscriptions
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
* fix(adapters): drop silent-sentinel row fallbacks across Apple Podcasts, Reddit, and Gitee
Continues the audit-baseline cleanup from #1611 (lesswrong) and #1631
(wikipedia / 36kr / xiaoyuzhou / zhihu), and follows the direction set
by 71646158 (silent-empty-fallback resolutions across Douyin / Jike /
WeRead) and ee54eb8e (ignore sentinels in thrown errors).
Replaces silent-sentinel row fallbacks with the empty-string signal so
agents can tell apart "field has value Unknown" from "upstream returned
no value":
- apple-podcasts/search: episodes, genre
- reddit/saved: title
- reddit/upvoted: title
- gitee/search: language, description
All four files audited for downstream sentinel checks via
`grep -nE "=== ?['\"](Unknown|unknown|-)['\"]"`. None reference the
swapped values in control flow (verified against the v2ex/me.js class
of regression caught in #1631).
Intentionally skipped in this batch (will not flip to empty):
- gitee/trending.js:272: downstream `project.description !== '-'`
check drives the mergedDescription fallback. Same control-flow
sentinel pattern as v2ex/me.js. Stays on baseline.
- web/read.js x4: `'-'` lives inside rendered diagnostic lines
(`lines.push(...)`), not row fields. Empty would render
` GET /a/b` with a doubled space. UX placeholder.
- yollomi/{edit,video}.js x6: `file: '-'`, `size: '-'`, `credits: '-'`
are user-facing status rows displayed to humans. Empty would
collapse columns visually.
- zsxq/dynamics.js: `title: '[${d.action || 'unknown'}]'` is a
template-literal-rendered title prefix. Empty would render `[]`.
Verified live: `opencli apple-podcasts search "lex fridman" --limit 2`
returns populated episodes/genre. `opencli gitee search "vue" --limit 2`
returns populated language/description. Baseline shrinks accordingly.
* test(adapters): add empty-signal coverage for the cluster-3 sentinel swap
Mirrors the cluster-2 test additions, pairing the sentinel value swap
in this PR with focused unit tests that mock the upstream to return
null / missing fields and assert the row surfaces an empty-string
signal instead of the old fabricated '-' / 'unknown' sentinel.
Coverage:
- clis/apple-podcasts/commands.test.js (+1 case): stubs the iTunes
Search response with a result that has collectionId / collectionName
/ artistName populated but no trackCount and no primaryGenreName.
Asserts episodes and genre render as '' (was '-' before this PR).
- clis/gitee/search.test.js (new): mocks Gitee's `so.gitee.com/v1/search`
fetch with two cases - a hit that has only title + url (no langs,
no description), and a hit that has all fields populated. Asserts
the missing fields render as '' (was '-' before) and that populated
fields pass through verbatim.
The reddit/saved and reddit/upvoted changes in this PR live inside a
page.evaluate template literal that fetches from reddit.com inside
the browser context, so the empty-signal branch is executed inside
the page rather than in adapter JS. They are 1-char `|| '-'` ->
`|| ''` swaps with no downstream sentinel consumer and the same JS
semantics demonstrated by the gitee + apple-podcasts tests above.
* chore: rebuild cli-manifest.json to drop stale entries from rebase
The previous rebase left a stale linkedin/people-search entry in
cli-manifest.json that was carried over from a sibling feature branch.
This branch does not include the people-search source file, so the
entry was an orphan; CI's build-manifest safety check correctly
refused to overwrite it. Regenerating with --allow-removals to drop
the orphaned entry, after which a normal `npm run build` is a no-op.
* fix(lesswrong): drop "Unknown" silent sentinel in author column
Twelve lesswrong commands had `author: item.user?.displayName ?? 'Unknown'`
which masks the missing-author signal: an agent reading the result row
cannot distinguish "post has no associated user" from "author is literally
named Unknown". The repo's typed-error lint flags this pattern
(silent-sentinel rule, see scripts/check-typed-error-lint.mjs:323).
Replace `?? 'Unknown'` with `?? ''` so the missing-author case stays
visible as an empty string. Consistent with `clis/lesswrong/_helpers.js:68`
which was already using the empty-signal form.
Shrinks scripts/typed-error-lint-baseline.json from 173 to 161 entries.
Follows the same direction as #1603 (fix(adapters): surface silent empty
fallbacks).
Verified live: `opencli lesswrong frontpage --limit 2 -f json` returns
real posts with non-empty author values; empty-author rows would now
show `"author": ""` instead of fabricating `"Unknown"`.
* test(lesswrong): add empty-signal coverage for the author sentinel swap
Per owner's pattern in 71646158 (douyin/user-videos.test.js +
jike/read.test.js + weread/search-regression.test.js), pairs the
silent-sentinel value swap in this PR with a focused unit test that
mocks the upstream LessWrong GraphQL response to return posts where
`user` is null or `user.displayName` is missing, and asserts the row
surfaces `author: ''` instead of the old fabricated `'Unknown'`.
`clis/lesswrong/frontpage.test.js` is representative for the twelve
identical `author: item.user?.displayName ?? ''` swaps across
comments / curated / frontpage / new / read / sequences / shortform /
tag / top / top-month / top-week / top-year, all of which share the
exact same expression with no downstream sentinel consumer.
The empty-signal path is exercised live too: a deleted-account or
permission-restricted user shows up in the GraphQL response with
`user: null`, surfaces as `author: ''` post this PR (was 'Unknown'
before).
* fix(adapters): drop silent-sentinel row fallbacks across 6 read commands
Continues the audit-baseline cleanup started in #1611 (lesswrong) and
the direction set by #1599 / #1603 / #1604. Replaces the
`silent-sentinel` row-data fallbacks (`'Unknown'` / `'-'` / `'unknown'`
that mask missing fields) with the empty-string signal so agents can
tell apart "field really has the value Unknown" from "upstream returned
no value".
Touched 6 read adapters, 10 baseline entries:
- wikipedia/trending: title, description
- 36kr/article: author, date, body
- xiaoyuzhou/download: podcast
- xiaoyuzhou/transcript: podcast
- zhihu/collection: dedup key + type field (the empty prefix still
produces a unique-per-content dedup key, just without the `unknown:`
noise)
- zhihu/download: author
Intentionally skipped (line-by-line audited):
- v2ex/me.js: `'Unknown'` is an in-band control-flow sentinel. Line 35
initialises `let username = 'Unknown';`, line 41 uses
`if (username === 'Unknown')` to trigger the profileEl fallback
selector, line 75 uses the same check to raise the auth error.
Empty would silently bypass both checks and return a row with an
empty username as if auth succeeded.
- v2ex/daily.js: `'未知'` is user-facing 签到 success text in the
rendered status message, not a row field. Empty would render a
broken sentence.
- weibo/comments.js, weibo/feed.js: the sentinel sits inside an in-IIFE
error-message string composition (`'API error: ' + (data.msg || 'unknown')`),
not in a returned row. Empty would silently truncate diagnostic
output. Both stay on baseline.
Verified live: `opencli wikipedia trending --limit 3` and `opencli 36kr
hot --limit 2` both return populated rows; the empty-string signal only
kicks in when the upstream value is actually missing.
* test(adapters): add empty-signal coverage for the cluster-2 sentinel swap
Per owner's pattern in 71646158 (douyin/user-videos.test.js +
jike/read.test.js + weread/search-regression.test.js), pairs the
silent-sentinel value swap in this PR with focused unit tests that
mock the upstream to return null / missing fields and assert the row
surfaces an empty-string signal instead of the old fabricated
'Unknown' / '-' / 'unknown' sentinel.
Coverage:
- clis/wikipedia/trending.test.js (new): mocks wikiFetch to return
three articles - one with both title + description populated, one
with no title and no description, one with title only. Asserts the
missing fields render as '' (was '-' before this PR).
- clis/36kr/article.test.js (new): mocks page.evaluate to return a
scrape where title is present but author / date / body are empty.
Asserts those three fields render as '' in the row pair output
(was '-' before this PR). Also covers the NOT_FOUND and
INVALID_ARGUMENT error paths that already existed.
- clis/zhihu/collection.test.js (+1 case): mocks the zhihu collection
API to return an item with content.id but no content.type. Asserts
type renders as '' (was 'unknown' before this PR); the new dedup
key prefix is :id rather than unknown:id, semantically identical
for dedup purposes.
The other three files in this PR (xiaoyuzhou/download,
xiaoyuzhou/transcript, zhihu/download) use the same `|| 'unknown'` ->
`|| ''` value swap with no downstream sentinel consumer. They are
covered by the same JS language semantics the three tests above
demonstrate.
* fix(adapters): fail typed on missing row identity
* fix(adapters): tighten sentinel row identity guards
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
* feat(linkedin): add messaging commands
Add fail-closed LinkedIn inbox, connect, safe-send, and thread-snapshot commands with adapter tests and docs.
* fix(linkedin): align commands with current UI
Update inbox to read LinkedIn's normalized messaging API response and connect to use the current custom-invite route.
* chore(linkedin): sync cli-manifest.json with rebuilt inbox command
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(linkedin): pass silent-column-drop gate
Drop the intermediate timestamp_ms field from inbox rows (it is converted to the timestamp column) and baseline the connect command internal profile-probe object.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(linkedin): validate inbox --limit with a typed error
Reject an out-of-range --limit with ArgumentError instead of silently clamping it, satisfying the typed-error lint gate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(linkedin): harden messaging command contracts
* fix(linkedin): reject inbox conversations without thread id
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
* enrich(coupang): add product detail cmd + replace silent clamp/sentinel/Error with typed errors
Two enrichment changes plus three silent-failure fixes on top of existing
search / add-to-cart.
New cmd: coupang product
─────────────────────────
Pairs with search as the listing↔detail round-trip target. Reads a logged-in
product page and extracts a single canonical row with price, original_price,
discount_rate, rating, review_count, seller, brand, rocket, delivery_promise,
image_url, url. Three-source extractor (JSON-LD Product schema → bootstrap
globals → DOM) merged in priority order, mirroring the search.js pattern.
The columns use string|null typing — null means "upstream did not provide
this field on this product" (e.g. some items have no original_price).
Failures (login wall / page mismatch / page failed to render) raise typed
errors instead of silently returning empty rows, so callers can treat any
returned row as real data.
Search column shape: added product_id
─────────────────────────────────────
Listing must pair with detail by id. The data was already extracted by
normalizeSearchItem; only the columns array needed updating so the field
projects through to the rendered row. Per the listing-id-pairing convention
(PR #1297) the new column lets agents round-trip rows directly into
`coupang product` without re-scraping URLs.
Silent-failure fixes
────────────────────
1. search --limit silent clamp.
Old: `Math.min(Math.max(Number(kwargs.limit||20),1),50)` silently
rewrote `--limit 999` to 50 and `--limit 0` to 1.
New: `parseLimitArg(raw, 20, 50)` throws ArgumentError on out-of-range
/ non-integer / negative input. Same convention as the typed-fail-fast
memory & PR #1289.
2. search --page silent clamp.
Old: `Math.max(Number(kwargs.page||1),1)` silently lifted negative pages.
New: parsePageArg throws ArgumentError on non-positive input.
3. Generic `throw new Error(...)` → typed errors.
- Empty query, unsupported --filter, missing --product-id/--url
→ ArgumentError
- Login wall detection → AuthRequiredError('coupang.com', ...)
- Empty result / filter-not-rendered → EmptyResultError
- PRODUCT_MISMATCH / OPTION_REQUIRED / button-not-found / unknown
ack failure (add-to-cart) → CommandExecutionError
- The PRODUCT_MISMATCH and `actualProductId || 'unknown'` sentinel were
also fixed (silent-sentinel was the audit hit there).
Coverage
────────
- 21 contract assertions in clis/coupang/coupang.test.js covering
parseLimitArg / parsePageArg (no silent clamp), registry shape (search has
product_id, product is read-class with expected columns, add-to-cart is
write-class), and typed-error pre-flight rejections (empty query / bad
filter / out-of-range limit & page / missing detail args).
- Manifest 763 → 764 (+1 entry: coupang/product).
- Audits: typed-error-lint 196 → 194 (resolved 2 silent-clamp/sentinel
baseline entries; baseline updated). silent-column-drop 103/103 unchanged.
* fix(coupang): tighten product id and browser errors
* fix(coupang): require real product urls
* fix(linkedin): surface detail_error on --details (no silent catch / no silent empty)
The previous --details enrichment path had two indistinguishable failure modes
that both produced `description: '', apply_url: ''`:
1. `if (!job.url)` early return — row had no jobId, so we couldn't navigate.
2. `} catch {}` — page.goto / page.evaluate threw (network, timeout, parse error).
Callers couldn't tell "upstream had no description" from "we failed to fetch",
and the catch swallowed every error without logging. For an enrichment that
costs one page navigation per row, silent failure is especially harmful — users
just see an empty cell with no way to debug.
Fix: replace empty strings with `null` for missing/failed rows, add a new
`detail_error` column (string|null) carrying a short typed reason:
- 'no url' — row had no jobId
- 'fetch failed: <msg>' — page.goto / page.evaluate threw
- 'missing description' — page loaded but body was empty
- null — success
Every failure is also logged to stderr with the offending URL so debugging is
possible. Per-row failures still don't abort the batch (the original intent),
but they're now visible.
Tests: 13 new contract assertions in clis/linkedin/search.test.js covering
parseCsvArg, mapFilterValues (ArgumentError on unknown values), decodeLinkedinRedirect,
and 5 enrichJobDetails paths (no-url / goto-throw / empty-description / success /
multi-row-mixed). Added `export const __test__` for testability.
Audits clean: typed-error-lint 196/196, silent-column-drop 103/103.
* fix(linkedin): fail fast on auth walls
PR #1297 introduced a CI gate that fails when a site has both a listing
and a detail command but the listing rows don't carry an id-shaped column.
The gate came with a 10-entry EXEMPT map (topic-string trending,
profile-attribute rows, UI-only sessions, ...) where each exemption
recorded a "why this listing legitimately doesn't pair" reason.
By the same filter that closed PR #1311 (write-without-delete-pair gate):
Is "listing should pair with detail" a *permanent* anti-pattern, or
case-by-case business judgment?
It's case-by-case. Topic-string listings and profile-attribute rows
genuinely don't pair with a detail command. The fact that we needed an
EXEMPT map with 10 entries and individual reason strings is the smell —
it's not the rule winning, it's the rule failing. Forcing every adapter
PR to either add an id column or file an exemption was a higher cognitive
cost than the silent-loss bugs the rule actually catches.
Changes:
- .github/workflows/ci.yml — drop the "Check listing↔detail id pairing"
step. Other gates (silent-column-drop, typed-error-lint) stay in place.
- package.json — rename the script from `check:listing-id-pairing` to
`advise:listing-id-pairing` to make the advisory nature explicit.
- scripts/check-listing-id-pairing.mjs — drop the `--strict` flag and the
EXEMPT map. The script now always exits 0 and prints an advisory report
of listings that don't carry an id-shaped column. Reviewers/authors use
it as guidance, not a gate.
- docs/conventions/listing-detail-id-pairing.md — rewrite from "MUST" to
"soft convention". Adds an explicit "why advisory, not a gate" section
that lists the legitimate non-pairing categories so future readers know
the rule's boundary.
- docs/developer/ts-adapter.md — match the advisory tone in the
adapter-author guidance.
The doc, the script, and the column patterns table all stay — agents and
adapter authors can still consult them. What's gone is the CI failure and
the per-PR exempt-list maintenance burden.
Net diff: -34 lines (gate + EXEMPT map removed, advisory-tone doc adds
a small "why advisory" section).
Adds a baseline CI gate for convention-audit typed-error lint findings. Also refreshes the silent-column-drop baseline for dianping changes already on main.
* feat(dianping): browser adapter — search + shop on www.dianping.com
Adds two browser-mode adapters for the dianping (大众点评) PC site:
- `dianping search "<keyword>" --city <name|id> --limit <n>`: keyword
shop/restaurant search. Returns rank, shop_id, name, rating, reviews,
price, cuisine, district, url. shop_id round-trips into `dianping shop`.
- `dianping shop <shop_id>` (alias `detail`): shop detail sheet
(field/value rows: name, rating, breakdown 口味/环境/服务/食材, reviews,
price, rank, hours, address, subway, features, url).
Both use Strategy.COOKIE on www.dianping.com (the PC site renders search
SSR and does not require JS hydration). m.dianping.com is intentionally
crippled for non-mobile UAs, so it's not used.
Auth detection (utils.detectAuthOrEmpty) inspects both response text and
final URL for the Meituan Yoda captcha redirect (verify.meituan.com) and
the dianping login redirect; raises AuthRequiredError with the captcha
URL embedded so the user can clear it manually in the same profile.
Listing↔detail id pairing: search.shop_id → shop.<id>. Adds 'shop' to
DETAIL_NAMES in scripts/check-listing-id-pairing.mjs so the convention
gate scans this site (35 sites / 78 listings now covered).
* fix(dianping): harden browser failure classification
* fix(dianping): fail on partial missing shop ids
* feat(convention): listing↔detail id pairing rule + CI gate
Adds a hard convention: when a site exposes both a listing-class command
(search / hot / top / recent / ...) and a detail-class command (read /
paper / article / view / ...), every listing row MUST surface an id-shaped
column whose value round-trips into the detail command. Without that, an
agent has no way to follow up on a listing row except re-searching by
title or scraping URLs out of band — both of which break the agent-native
contract.
What's in this PR
- docs/conventions/listing-detail-id-pairing.md — full rule, examples
table, why-it-matters, what counts as id-shaped, exemption taxonomy,
how to add an id column to a listing.
- scripts/check-listing-id-pairing.mjs — validator that reads
cli-manifest.json, classifies each entry as listing / detail / other,
and fails when a listing on a site that also has a read-detail command
is missing an id-shaped column. Exemption allowlist records WHY each
pair is exempt so future maintainers know what to verify.
- npm run check:listing-id-pairing — strict-mode wrapper.
- CI: new step in build job runs the validator after the manifest
freshness check on Linux.
- docs/developer/ts-adapter.md — cross-link from the adapter authoring
guide.
- docs/.vitepress/config.mts — sidebar entries for the new conventions
section.
Fixes brought to zero violations
- 1688/search: add offer_id (already extracted, just surfaced)
- bluesky/user: add uri (AT URI round-trips into bluesky/thread)
- tieba/search: add id + url (thread_id already extracted)
- tieba/hot: add url (rows are topics, not threads — url is the
best-effort round-trip handle, doc'd as such)
Exemptions (intentional, doc'd in EXEMPT map with rationale)
- nowcoder/hot, bluesky/trending, twitter/trending — listing rows are
topic strings, not posts.
- lesswrong/user, reddit/user — rows are profile-attribute key/value
pairs, addressed by the username arg.
- discord-app/search — desktop UI session, message ids not extractable.
- notion/search — Strategy.UI Quick Find, page ids not exposed in DOM.
Validator output after this PR: 32 sites scanned, 75 listings checked,
7 exempted, 0 violations.
* fix(convention): tighten listing id gate
* fix(convention): close url-derived id loophole
* fix: clean up stale .yaml adapter files from older versions (#953)
Users upgrading from v1.6.x retain .yaml adapter files in
~/.opencli/clis/ that trigger "Ignoring YAML adapter" warnings on
every run. The hash-based sync only tracks .js files, so these
legacy .yaml files are never cleaned up.
Add a cleanup step (3b) that removes .yaml/.yml files from user
adapter directories when the corresponding site exists in the
official package (i.e., the site has been migrated to .js).
* fix(fetch-adapters): narrow stale yaml cleanup
* fix: code audit round 2 — pruneEmptyDirs, evaluateWithArgs, hot-reload, error cause chain
1. pruneEmptyDirs: use path.relative() instead of startsWith() to prevent
false boundary matches on overlapping directory names
2. evaluateWithArgs: add safe evaluate method that auto-serializes args via
JSON.stringify, preventing injection by design
3. Hot-reload: detect mtime changes on user adapter files in daemon mode,
invalidate module cache so edits take effect without restart
4. toEnvelope: preserve error cause chain in verbose mode for better
production debugging
* fix: address review feedback on code audit round 2
- pruneEmptyDirs: resolve() paths before relative() check
- evaluateWithArgs: validate keys are valid JS identifiers
- hot-reload: only bust ESM cache on reload, not first load
- toEnvelope: move cause serialization into toEnvelope itself
so all consumers (AI agents, MCP tools) get cause chain
* fix: address code audit findings (C1-C4, I1, I4, I6)
Security:
- C1: Fix page.evaluate injection in browser type/select commands and
6 adapter files by using JSON.stringify for user input interpolation
- C2: Close WebSocket on CDP connect timeout to prevent resource leak
- C3: Reject CDP connect promise on Page.enable failure instead of
silently swallowing the error
Reliability:
- C4: Guard against corrupted adapter-manifest.json hashes to prevent
false-positive override deletion
- I1: Throw on pre-navigation failure instead of warn-and-continue
- I4: Use Map<string, Promise<void>> for lazy module loading to prevent
concurrent double-imports of the same adapter
Performance:
- I6: Replace O(n) registry alias cleanup with O(k) direct deletion
* fix: address self-review findings on PR #981
- C1: add quotes around CSS selector attribute values in browser
type/select to match other commands (get text/value/attributes)
- C2: clear this._ws in timeout handler to prevent race with open event
- C4: refine corruption guard — treat null/undefined hashes as empty,
only skip sync for truly invalid types (string, number, array)
* refactor: smart sync adapters instead of full copy (#sparse-override)
Replace unconditional full-copy of all adapters to ~/.opencli/clis/ with
hash-based smart sync that only copies files whose content has changed.
Changes:
- fetch-adapters.js: use SHA-256 content hashes to skip unchanged files;
store per-file hashes in adapter-manifest.json
- discovery.ts: simplify ensureUserAdapters() to only create the directory
(no longer triggers full copy on first run)
- main.ts: fix fast completion to check manifest file existence instead of
directory existence (sparse override may have empty user dir)
- cli.ts: add `opencli adapter eject/reset/status` commands for managing
local adapter overrides
- engine.test.ts: add tests for empty user dir and ensureUserAdapters
* fix: address review blockers — site-level sync + reset --all
1. Fix `adapter reset --all`: change <site> from required to optional
argument so --all can be used without specifying a site name.
2. Change smart sync from file-level to site-level granularity:
if any file in a site has changed upstream, overwrite the entire
site directory. This matches the agreed product semantics — local
modifications to any file in a site are replaced when upstream
updates that site.
* fix: delete old site dir before writing updated adapter files
When a site has upstream changes, delete the entire site directory
first, then write the new version. This prevents stale files from
older versions lingering in the user directory.
* fix: reset --all preserves custom sites, only removes official overrides
Blocker 3 fix: reset --all now checks BUILTIN_CLIS to identify official
sites and only deletes those, preserving user-created custom sites.
* refactor: sparse sync deletes local overrides instead of copying new versions
Changed fetch-adapters.js semantics per team agreement:
- When an official site has upstream changes, DELETE the local override
instead of copying the new version into ~/.opencli/clis/
- Runtime automatically falls back to package baseline
- ~/.opencli/clis/ becomes a true sparse override layer
* fix: reset <site> rejects custom sites, only allows official overrides
Single-site reset now checks BUILTIN_CLIS before deleting, matching
the same protection that reset --all already has.
* fix: reset <site> allows custom sites per product decision
Per @WAWQAQ: explicit single-site reset should work on custom sites too.
Differentiate messaging: official sites say "using official baseline",
custom sites say "removed custom site".
reset --all still only removes official overrides (bulk safety).
* fix: reset --all deletes all local sites including custom per product decision
Per @WAWQAQ: --all should clear the entire local working cache,
including custom sites. Single-site reset already handles both types.
Older versions (pre-1.7.1) shipped adapters as .ts files. When users
upgrade to a .js-only version, the old .ts files are left orphaned in
~/.opencli/clis/. Add a cleanup step that removes .ts files when a
corresponding .js official adapter exists.
* fix: project hygiene — docs, lint, daemon restart, code fence
- Update Node version requirement from >= 20 to >= 21 in 7 doc files
(README, README.zh-CN, installation guides, troubleshooting)
- Update adapter count from 79+ to 87+ in READMEs
- Remove duplicate `lint` script (identical to `typecheck`)
- Fix TESTING.md CI matrix: Node ['22'] instead of ['20', '22']
- Fix autofix SKILL.md code fence escaping (\``` → ~~~)
- Add daemon restart to postinstall so updated adapters are picked up
- Fix preuninstall to respect OPENCLI_DAEMON_PORT env var
* fix: align docs and skills with JS-first adapter contract
Adapters are now .js files (not .ts). Update all references across:
- README.md, README.zh-CN.md, CONTRIBUTING.md
- docs/guide/getting-started.md, docs/index.md
- skills/opencli-browser/SKILL.md, skills/opencli-explorer/SKILL.md
The runtime (discovery.ts) only loads .js from user clis/ directories,
and `opencli browser init` generates .js scaffolds. Documentation was
still teaching users to create .ts files.
* fix: update CI matrix to Node 22 only (drop Node 20)
package.json requires Node >= 21 (styleText dependency). The CI matrix
was still testing Node 20 which doesn't meet this requirement.
* fix: revert incorrect daemon restart from postinstall
The daemon (browser bridge) only handles CDP communication — it has no
knowledge of adapters. Adapter discovery, loading, and execution all
happen in the CLI process, which is fresh each invocation. The
_loadedModules cache in execution.ts is process-local and not a real
staleness concern. Remove the unnecessary restartDaemon() call.
* fix: sync package-lock.json with package.json dependencies
package-lock.json was missing @emnapi/core@1.9.2 and
@emnapi/runtime@1.9.2 (transitive deps of @emnapi/wasi-threads),
causing `npm ci` to fail on all CI jobs.
* fix: resolve remaining CI failures after TS-to-JS adapter migration
- vitest.config.ts: update adapter project include/exclude from .test.ts
to .test.{ts,js} to match converted adapter test files
- check-doc-coverage.sh: skip adapter directories containing only utility
files (prefixed with _), fixing false positive for clis/slock/
- linux-do/topic-content.test.js: fix hardcoded reference to topic.ts
(now topic.js after PR #928 migration)
* fix: clean up legacy shim files and stale tmp files on upgrade
Add cleanup steps to fetch-adapters.js that run on every version upgrade:
1. Remove legacy compat shim files from ~/.opencli/ (registry.js,
errors.js, utils.js, etc.) that were created by an older approach
using file:// re-exports. Current approach uses node_modules symlink.
Only deletes files containing "export * from 'file://" to avoid
removing user-created files.
2. Remove legacy compat shim directories (browser/, download/, errors/,
etc.) using the same safety check.
3. Clean up stale .plugins.lock.json.tmp-* files left behind by
crashed processes. These accumulate over time (108 found on one
machine) and clutter ~/.opencli/.
* fix: check every file in legacy shim directories before deleting
Instead of checking only the first file and deleting the entire
directory, now checks each file individually and only deletes files
matching the shim pattern. Directory is removed only if empty after
individual file cleanup.
- Remove mapDistToSource() from diagnostic.ts — mapped dist/clis/
paths back to clis/ but dist/clis/ no longer exists after JS-first
migration. The function always returned null.
- Simplify resolveAdapterSourcePath() to check candidates directly
without the dead dist→source mapping detour.
- Delete scripts/clean-yaml.cjs — walked dist/clis/ to delete YAML
files, but dist/clis/ no longer exists.
- Remove clean-yaml script entry from package.json.
* refactor(adapters): convert adapter layer from TypeScript to JavaScript
Core framework stays TypeScript; adapter layer moves to JS-first.
Adapters are essentially "executable config + browser scripts" that
barely use TS features — this simplifies the build/distribution pipeline
by removing the dist/clis/ intermediate compilation step.
Changes:
- Convert all 753 adapter files in clis/ from .ts to .js
- Update tsconfig to exclude clis/ from compilation
- Simplify build-manifest to scan clis/*.js directly (no dist/clis/)
- Update discovery, main, fetch-adapters to load JS adapters from clis/
- Update generate-verified to output .js artifacts
- Update package.json files field: dist/clis/ → clis/
- Fix all test files for the .ts → .js transition
* fix(main): use findPackageRoot for BUILTIN_CLIS path
The previous relative path (../../clis from __dirname) only worked for
dist/src/main.js but broke dev mode (tsx src/main.ts) where __dirname
is <repo>/src — resolving to /clis instead of <repo>/clis.
Use findPackageRoot() which works for both dev and prod paths.
* fix: avoid inserting completion config inside multi-line shell commands
The postinstall zshrc insertion logic splits backslash-continued blocks
(e.g. zinit stanzas) when it finds a compinit match inside them, which
breaks the user's shell config. Walk backward past continuation lines
so the insertion lands before the entire logical command.
* fix: append zsh completion to end of .zshrc instead of splicing
Replace the fragile compinit-searching splice logic with a simple
append, matching the strategy already used for bash. This avoids
breaking multi-line commands (e.g. zinit blocks with zicompinit).
Still detects existing compinit to avoid adding a duplicate call.
* fix: stop modifying shell rc files in postinstall
Replace the fragile .zshrc/.bashrc modification logic with a safer
approach: only write completion files and print setup instructions.
The previous approach tried to parse and splice into rc files, which
broke multi-line shell commands (e.g. zinit blocks with backslash
continuations matching /compinit/). Instead of attempting to fix the
parser, remove rc modification entirely — this matches the approach
used by rustup, homebrew, and other CLI tools.
Closes#788
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
* fix: review follow-ups — better first-run log, OPENCLI_FETCH=1 skips version check
- Clarify first-run log message: "copying adapters (one-time setup)"
- Add comment explaining why scriptPath uses two levels of ../
- OPENCLI_FETCH=1 now bypasses version-skip to allow forced refresh
* fix: update doc-coverage script path after clis/ move
check-doc-coverage.sh still referenced src/clis/ after PR #782 moved
adapters to root clis/. This caused CI to fail with "0/1 documented".
* fix: resolve package root dynamically for symlink and first-run paths
The symlink at ~/.opencli/node_modules/@jackwener/opencli pointed to
dist/ instead of the package root in prod mode, breaking user TS CLIs
that import from '@jackwener/opencli/registry'.
The first-run scriptPath also resolved incorrectly in dev mode.
Extract findPackageRoot() that walks up to find package.json, fixing
both paths for dev (src/) and prod (dist/src/) layouts.
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation
Separates CLI adapters from the core runtime to prepare for independent
adapter distribution via postinstall fetch.
Key changes:
- Move src/clis/ → clis/ (adapters at repo root)
- Change tsconfig rootDir from "src" to "." so tsc compiles both
- Create root-level shim files (registry.ts, errors.ts, etc.) so adapter
relative imports (../../registry.js) resolve correctly
- Update build-manifest.ts, main.ts paths for new dist/src/ structure
- Expand ensureUserCliCompatShims() to cover all adapter import targets
(types, utils, logger, launcher, browser/*, download/*, pipeline/*)
- Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync
- Update vitest.config.ts adapter test paths
- Add package.json files field to exclude adapters from npm package
Official adapter files are unconditionally overwritten on update;
user-created files not in the manifest are preserved.
* fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters
- Add dist/clis/ and dist/cli-manifest.json to package.json files field
so built-in adapters and manifest ship with the npm package
- Replace execSync with execFileSync to prevent command injection
- Add version check to skip redundant adapter fetches
- Track tmpRoot explicitly for reliable cleanup
* fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback
1. Manifest-based update strategy:
- Read old manifest to identify previously-official files
- Clean up files removed upstream (in old manifest but not new)
- User-created files (never in any manifest) remain untouched
2. Only run fetch-adapters on global install (npm_config_global=true)
or explicit OPENCLI_FETCH=1, preventing heavy side effects for
local/dev installs
3. First-run fallback in discovery.ts:
- ensureUserAdapters() checks for adapter-manifest.json
- If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js
- Guarantees adapters are available even with --ignore-scripts
* fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead
* feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger
* simplify: replace git clone with local copy from dist/clis/
Adapters already ship in the npm package (dist/clis/), so there's no
need to clone from GitHub. Copy directly from the installed package:
- Eliminates git, curl, tar dependencies
- No network calls in postinstall
- No timeout/offline issues
- Version always matches the installed CLI
- ~65 lines of clone/download code replaced by one cpSync loop
* feat(spotify): add Spotify playback adapter
Adds a new adapter for controlling Spotify via the official Web API.
Uses Strategy.PUBLIC with OAuth2 — no browser session required.
Commands: auth, status, play, pause, next, prev, volume, search, queue, shuffle, repeat.
Credentials are loaded from ~/.opencli/spotify.env or environment variables.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(spotify): rename index.ts → spotify.ts and fix CliError calls
- Renamed src/clis/spotify/index.ts to spotify.ts so the build-manifest
picks it up (index.js is intentionally excluded from manifest scanning)
- Fixed 4 CliError calls: constructor now requires (code, message, hint?)
so each throw now passes an appropriate error code as first argument
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(spotify): fix token refresh corruption, env parse, null guards, validation
- refreshAccessToken: check res.ok before parsing; construct Tokens object
directly instead of mutating loadTokens() result to avoid writing
undefined/NaN on Spotify error responses; preserve existing refresh_token
when Spotify omits it from the response
- loadEnv: split on first '=' only so values containing '=' are preserved
- SCOPES: remove write/library/top scopes not used by any command
- status: guard against data.item being null (active device but no track)
- volume: validate 0-100 range before API call
- auth: check tokenRes.ok on initial token exchange; add server.on('error')
handler for EADDRINUSE; add 5-minute timeout with clearTimeout on close
* feat(postinstall): auto-create ~/.opencli/spotify.env template on install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(spotify): guard null progress, podcast items, missing tracks data, corrupted tokens, invalid search limit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(spotify): improve missing credentials error with step-by-step guidance
* fix(spotify): harden setup and add docs coverage
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
* feat: zero onboarding, extension version check, and update notifier
- Fail-fast guard in execution.ts: when daemon is running but extension
is not connected, immediately surface a setup guide instead of waiting
for the 30s connect timeout
- Extension version handshake: extension sends `hello` with its version
on WebSocket connect; daemon stores it and exposes via /status; CLI
warns on mismatch in both execution path and `opencli doctor`
- `opencli doctor` now shows extension version inline and reports
version mismatch as an actionable issue
- Non-blocking npm update checker: registers a process exit hook so the
update notice appears after command output (same pattern as npm/gh/yarn);
background fetch writes to ~/.opencli/update-check.json for next run
- postinstall: print Browser Bridge setup instructions after shell
completion install for first-time global install users
Bug fixes caught in review:
- discover.ts: add AbortController timeout to checkDaemonStatus() fetch,
move clearTimeout after res.json() to cover body streaming
- daemon.ts: clear extensionVersion and reject pending requests in
ws.on('error') handler, not just ws.on('close')
- update-check.ts: skip update notice when process exits with non-zero
code; read cache once at module load to avoid double disk I/O;
guard isNewer() against NaN from pre-release version strings
* fix: reduce fail-fast timeout to 300ms and guard stderr.write in exit hook