"MANDATORY: SUPPORTING SKILL for cli-tool-expert. Parent sessions must delegate CLI browser lifecycle work to the cli-tool-expert agent. DO NOT perform CLI browser lifecycle work inline outside cli-tool-expert. When loaded inside cli-tool-expert, covers BrowserAutomation base class, AuthVerifier integration, browser auth lifecycle, and scaffold templates for CLI tools. Triggers: browser automation, add browser support, browser cli, BrowserAutomation, browser auth, playwright-cli session, browser login, is_authenticated, get_browser, browser session, add browser to cli, browser cli expert."
Install
npx skillscat add adbertram/cli-tools/cli-tool-browser-expert Install via the SkillsCat registry.
When the current agent is cli-tool-expert, follow this skill normally.
</agent_routing>
| Intent | Route |
|---|---|
| Add browser support to existing CLI | workflows/add-browser-support.md |
| Create new browser CLI from scratch | Use the cli-tool skill with --type browser |
| Fix browser auth issues | workflows/troubleshoot-browser.md |
| Understand the architecture | references/architecture.md |
| </quick_start> |
from cli_tools_common.browser_automation import BrowserAutomation
from .config import get_config
class MyBrowser(BrowserAutomation):
SESSION_NAME = "myservice"
LOGIN_URL = "https://myservice.com/login"
AUTH_CHECK_URL = "https://myservice.com/dashboard"
AUTH_URL_PATTERN = r"/login|/register"
AUTH_SUCCESS_SELECTOR = 'selector-visible-when-logged-in'
def __init__(self, config=None):
config = config or get_config()
super().__init__(config)If browser.py has custom methods beyond __init__, something is wrong.
def is_logged_in/def is_authenticated/def check_authdef _ensure_logged_in/def verify_session/def check_sessiondef _check_browser_status/def check_browser_session- Direct
sync_playwright()orasync_playwright()imports - Direct
launch_persistent_contextcalls - Custom
BrowserServiceclasses (legacy pattern)
~/.local/share/cli-tools/_shared/chromium-profile
Resolved by BaseConfig.get_persistent_profile_dir() / uses_shared_chromium_profile().
New browser CLIs need zero per-CLI glue — the shared package owns the path.
Stay isolated when:
- the active auth profile name is not
default(multi-account tools), or CLI_TOOLS_ISOLATE_CHROME_PROFILE=1, or- the config does not declare
CredentialType.BROWSER_SESSION.
Overrides: CLI_TOOLS_SHARED_CHROME_PROFILE=/absolute/or/~/path changes the shared dir.
Concurrency: Chrome allows only one process on a given user-data-dir. Run browser CLIs that need Chrome sequentially, or isolate.
Logout: clear_session() clears tool-local browser-data/ but does not wipe the shared profile (so one CLI logout does not log every tool out of Google). Use clear_shared_chromium_profile() for a full shared reset.
Tests: cli-tools-shared/tests/test_shared_chromium_profile.py.
- Add browser support to an existing API or wrapper CLI
- Fix browser auth (selector issues, session problems, false negatives)
- Understand architecture (how BrowserAutomation/AuthVerifier work)
- Review compliance (check if a CLI follows best practices)
- Something else
Wait for response before proceeding.
After reading the workflow, follow it exactly.
Architecture: architecture.md (full system design, class hierarchy, data flow)
Hooks: hooks-reference.md (all BrowserAutomation class constants and overridable methods)
Dual Auth: dual-auth.md (combining browser_session with OAuth/API key)
Scaffold: See templates/scaffold/ for complete file examples
</reference_index>
Known Issues
1. BrowserAutomation persists profile but not storage_state — httpx-backed reads fail with misleading "Session expired"
Symptom: A CLI that combines BrowserAutomation (for login) with BrowserAuthState / BrowserAuthenticatedHttpClient (for httpx-backed reads) reports Error: Session expired. Run '<cli> auth login' to re-authenticate. even when the user is fully logged in. auth status says authenticated (because it runs a live browser check), but data commands (e.g. doordash orders list) fail. The persistent Playwright profile under ~/Library/Caches/ms-playwright/daemon/ud-<session>/ has valid cookies; the Playwright storage_state JSON at ~/.local/share/cli-tools/<tool>/.profiles/<profile>/browser-data/auth-state.json is missing or empty. A secondary symptom is Browser auth failed: EOF when reading a line when auth login --force is run in a context where stdin is closed or piped.
Cause: BrowserAutomation.authenticate() historically wrote only a profile.json marker and an optional _on_authenticated() hook — it never unconditionally captured page.context.storage_state(path=...). Subclasses that didn't override _on_authenticated (the common case) ended up with a session marker but no storage_state file. has_session() returned True based solely on the marker, so subsequent auth login calls short-circuited with "Already authenticated" and never wrote the file the HTTP client needs. The marker and the storage_state had become two parallel session stores that could disagree. The EOFError was a separate but related symptom: bare input() cannot read from a non-TTY stdin.
Fix: Apply ALL of the following in cli_tools_common/browser_automation.py — never in per-CLI subclasses:
- Add
_state_file_path()returning<user_browser_data_dir>/auth-state.json. The path MUST resolve through_get_browser_data_dir()(which goes throughconfig.get_browser_data_dir()), so the file lives under~/.local/share/cli-tools/<tool>/.profiles/<profile>/browser-data/— NOT inside any CLI source repo under~/Dropbox/GitRepos/cli-tools/. CRITICAL path constraint. - Add
_save_auth_state()that callsself._get_service().state_save(str(state_file)). RaiseBrowserAutomationErroron any underlyingPlaywrightServiceError— no fallback / no silent recovery. - In
authenticate(): after the user confirms login and the marker is written, callget_page(self.AUTH_CHECK_URL)then_save_auth_state()unconditionally — BEFORE running the optional_on_authenticatedhook. - In
is_authenticated(): when the live check returns True, call_save_auth_state()to refresh the snapshot, and call_write_marker(browser="chrome")if the marker is missing. Cookies rotate; the on-disk snapshot must stay current, and an already-authenticated persistent profile must bootstrap both files on the first live check. - In
has_session(): require BOTHprofile.jsonAND a non-emptyauth-state.jsonunder the user data dir before returning True. Marker-only no longer counts. - Replace the bare
input()confirmation with an EOFError-safe prompt that falls back to reading/dev/ttyand exits with status 2 if neither is available.
Verification:
- Delete the existing snapshot:
rm -f ~/.local/share/cli-tools/<tool>/.profiles/default/browser-data/auth-state.json ~/.local/share/cli-tools/<tool>/.profiles/default/browser-data/profile.json. - With the persistent Playwright profile already logged in, run
<tool> auth status. The live check should succeed AND auto-write both files under the user data dir. - Confirm path:
ls -la ~/.local/share/cli-tools/<tool>/.profiles/default/browser-data/auth-state.json. It MUST live under~/.local/share/cli-tools/— never under~/Dropbox/GitRepos/. - Run a data command (e.g.
<tool> orders list --table). Must succeed, not raise "Session expired". - Run
pytest tests/test_browser_automation.pyincli-tools-common. The compliance tests cover state_save invocation, the user-data-dir path constraint, the both-fileshas_sessionrule, the live-check auto-refresh, and the EOF-safe prompt.
Recurrence Prevention: The fix lives entirely in the shared BrowserAutomation base class, so every CLI that combines persistent browser auth with httpx-backed reads (DoorDash, etc.) inherits the correct behavior without per-CLI code. Unit tests in cli-tools-common/tests/test_browser_automation.py assert: state_save is called during authenticate(), the snapshot path lives under the user data dir (NOT inside a repo), has_session() requires both files, and is_authenticated() refreshes the snapshot on a successful live check. If a future change ever resolves the auth-state path relative to the cwd or any source-tree location, test_authenticate_saves_storage_state_under_user_data_dir fails immediately. CRITICAL: never write auth-state.json anywhere inside ~/Dropbox/GitRepos/cli-tools/ — the user data dir resolved by config.get_browser_data_dir() is the only correct location.
General rule: When a shared base class manages dual on-disk state stores (e.g. session marker + cookie/state snapshot), every code path that confirms the session must keep both files in sync. Splitting that responsibility across subclasses or optional hooks guarantees that some consumer ends up reading a stale or missing file and reporting a misleading error.
2. auth status and auth login lie about session validity by trusting on-disk state instead of round-tripping
Symptom: <cli> auth status reports credential_types.browser_session.authenticated: true (and authenticated: true at the top level) for any CLI with CredentialType.BROWSER_SESSION whose persistent session has expired server-side. The very next data command (bricklink messages list, cj relationships apply, doordash orders list, etc.) immediately fails with Error: Session expired. Please login again with '<cli> auth login'.. Running <cli> auth login at that point also lies: it prints ✓ Already authenticated (<cli> browser session) and exits without re-authenticating. The same class of bug applies to non-expiring OAuth (OAuth 1.0a / static-credential OAuth, OAUTH_TOKEN_EXPIRES=False): auth status reports oauth_status: "valid" based purely on the presence of all four credential fields in .env, never actually round-tripping to the API. The user directive that drives this entry: "auth status must do LIVE checks with the auth method in question always to ensure accuracy."
Cause: This is a deliberate policy reversal of an earlier "auth status is filesystem-only" position. Three independent code paths were trusting on-disk state as proof of being authenticated:
AuthVerifier._check_browserdelegated tobrowser.has_session()(marker + non-emptyauth-state.jsonexist) and never calledbrowser.is_authenticated()— so any cookie that had been server-side-revoked or expired in place still reportedauthenticated: true.AuthVerifier._verify_single_typefor OAuth withOAUTH_TOKEN_EXPIRES=Falsereturnedoauth_status: "valid"solely based on_has_static_oauth_credentials()— a presence check acrossOAUTH_STATIC_REQUIRED_FIELDS. No API call. Revoked OAuth1 tokens reported as valid._handle_browser_logininauth_commands.pyshort-circuited onbrowser.has_session()alone, printing "Already authenticated" and skipping the interactive flow even when the saved cookies were dead. Worse: when the user--forced, the innerBrowserAutomation.authenticate()also short-circuited onhas_session()if force wasn't propagated correctly, so the browser never opened.
The earlier policy (status is filesystem-only, never instantiate a browser) optimized for cheap status — but the cost was that auth status couldn't tell the truth about whether the session was actually usable. Users were repeatedly hitting "status says authenticated → next command says expired" and could not trust the status command. The user explicitly chose accuracy over speed: every auth method must be verified with a real round-trip, every time.
Fix: Apply ALL of the following in cli-tools-shared/cli_tools_shared/:
auth_verifier.py::_check_browser— whenbrowser.has_session()is True, callbrowser.is_authenticated()and use the live result as the source of truth forauthenticatedandavailable. Close the browser in atry/finally. Whenhas_session()is False there is nothing to live-check, so skip the probe. Coerce bothAuthResultand plain bool return shapes (bool(live)+getattr(live, "available", authenticated)).auth_verifier.py::_verify_single_typefor OAuth withOAUTH_TOKEN_EXPIRES=False— delegate to_check_api()exactly like API_KEY types do. When the live test passes setoauth_status="valid"+authenticated=True; when it fails setoauth_status="invalid"+api_test="failed: ..."+authenticated=False; when no handler is wired setoauth_status="saved"+api_test="skipped: no test handler"+authenticated=False(we have credentials but no way to verify they work — do NOT claim valid).auth_commands.py::create_auth_app— resolveeffective_test_handlerONCE at the top of the factory and pass it to BOTHauth statusandauth test. The historical code only passed it toauth test, soauth statuswas structurally incapable of live-verifying OAuth even when a handler existed.auth_commands.py::_handle_browser_login— before claiming "Already authenticated", callbrowser.is_authenticated()afterhas_session(). If the live check fails, print "Saved session is no longer valid — re-running browser login." and proceed tobrowser.login(force=True)— theforce=Trueis REQUIRED so the innerauthenticate()doesn't short-circuit on its ownhas_session()check.- Per-CLI
AUTH_URL_PATTERN— audit for regex patterns that don't actually match the real expired-session landing URL. Bricklink's pattern wasidentity\.lego\.com/loginbut the live redirect isidentity.lego.com/en-US/login?ReturnUrl=..., so the locale segment broke the match and_check_authreported authenticated when it wasn't. Fixed pattern:identity\.lego\.com/[^?]*login|/v2/login\.page. Always validateAUTH_URL_PATTERNagainst the actual expired-session URL by deliberately invalidating the session and capturingpage.url.
Verification:
cd cli-tools-shared && UV_PROJECT_ENVIRONMENT=~/.cache/uv/project-envs/cli-tools-shared-tests uv run pytest tests/test_auth_verifier.py tests/test_auth_commands.py -v— 40 tests pass, includingTestAuthStatusLiveVerifiesBrowserSession::test_status_reports_false_when_session_files_exist_but_live_check_failsandtest_browser_session_login_falls_through_when_live_check_fails.- Full suite:
cd cli-tools-shared && UV_PROJECT_ENVIRONMENT=~/.cache/uv/project-envs/cli-tools-shared-tests uv run pytest— 254 passed. - End-to-end reproduction with bricklink:
- Before fix:
bricklink auth statusreturnsbrowser_session.authenticated: truewhilebricklink messages list --limit 1immediately errors withSession expired. - After fix:
bricklink auth statusreturnsbrowser_session.authenticated: falseand the JSON top-level may still reportauthenticated: trueonly via the OAuth pathway (OR-over-configured types).bricklink auth loginprints "Saved session is no longer valid — re-running browser login." instead of "Already authenticated".
- Before fix:
Recurrence Prevention: Three regression test classes in cli-tools-shared/tests/test_auth_verifier.py pin the new contract:
TestAuthStatusLiveVerifiesBrowserSession::test_status_reports_false_when_session_files_exist_but_live_check_failsasserts that whenhas_session()returns True andis_authenticated()returns False, the resulting block reportscredentials_saved: truebutbrowser_session: falseandauthenticated: false. This is the canonical bricklink-style scenario.TestVerifyOutputFields::test_static_oauth_credentials_with_failed_live_test_report_invalidasserts that saved OAuth1 credentials whose live API call raises produceoauth_status: "invalid"+authenticated: false— NOT "valid".TestVerifyOutputFields::test_static_oauth_credentials_without_test_handler_report_unverifiedasserts that saved OAuth1 credentials with no test handler produceoauth_status: "saved"+authenticated: false— we never claim valid without proof.
In tests/test_auth_commands.py, test_browser_session_login_live_verifies_before_claiming_already_authenticated and test_browser_session_login_falls_through_when_live_check_fails lock the auth login contract. The fall-through test additionally asserts browser.login.assert_called_once_with(force=True) so a future change that drops the force-propagation fails immediately.
The OLDER guard tests that pinned the opposite "auth status is filesystem-only" contract (TestAuthStatusDoesNotLaunchBrowser in this file, plus browser.is_authenticated.assert_not_called() assertions across multiple tests) were intentionally rewritten in this policy reversal. If you find them in any restored older file, treat them as stale — they document an explicitly rejected previous direction.
General rule: Status / login / "report current state" commands for auth MUST do a live round-trip per auth method, not infer from on-disk artifacts. Credentials on disk can be revoked, expired, or rotated server-side without our knowledge. Trusting has_session() / has_credentials() / "all four OAuth fields are set" as proof of being authenticated produces lying status reports and broken already-authenticated short-circuits. This is more expensive than a filesystem check (a network round-trip per type, a browser launch for browser_session) — that is the deliberately accepted cost of telling the truth.
3. auth status and the command credential gate disagree about the same browser session
Symptom: <cli> auth status reports credential_types.browser_session.authenticated: true for a CLI that declares CredentialType.BROWSER_SESSION but no AUTH_STORAGE_KEY and no AUTH_COOKIE_PATTERNS (e.g. cj). The next data command (cj relationships apply --dry-run 7453049) immediately fails with Authentication required. Missing credentials: - browser_session: browser session expired. The two surfaces inspect the same on-disk session and produce opposite verdicts. Re-running cj auth login reports "already authenticated" and does nothing — the disagreement persists across runs.
Cause: Two different code paths verify the same browser session and the defaults disagree. auth status runs through AuthVerifier._check_browser, which is filesystem-only via browser.has_session() (marker + non-empty auth-state.json). The dispatch-time credential gate runs through cli_tools_common.command_registry._check_credentials, which first tries _check_browser_saved_auth (looking at AUTH_STORAGE_KEY for localStorage or AUTH_COOKIE_PATTERNS for cookies). When the browser subclass declares neither hook, _check_browser_saved_auth returns None and the historical gate fell back to browser.is_authenticated() — a live navigation to AUTH_CHECK_URL that can fail when cookies are stale even while the on-disk session is intact (transient network issue, cookie rotation, slow SPA hydrate, headed-mode race). Same resource, two checks, two verdicts.
Fix: In cli_tools_common/command_registry.py::_check_credentials, when the BROWSER_SESSION branch sees _check_browser_saved_auth return None, fall back to browser.has_session() — the same filesystem inspection auth status uses — instead of browser.is_authenticated(). The gate and auth status now agree by construction for every browser-session CLI. Commands that genuinely need a live check (the real apply path in cj relationships apply, real read paths in DoorDash, etc.) still perform browser.is_authenticated() themselves at the point of use; the difference is the dispatch gate no longer rejects commands based on a transient live-navigation failure that disagrees with the rest of the system. CLIs that need a stricter dispatch-time check should declare AUTH_STORAGE_KEY or AUTH_COOKIE_PATTERNS on the browser subclass — that turns on the deterministic saved-auth check rather than the flaky live check.
Verification:
cd cli-tools-common && UV_PROJECT_ENVIRONMENT=~/.cache/uv/project-envs/cli-tools-common-tests uv run pytest tests/test_command_registry.py—test_browser_session_gate_uses_has_session_without_storage_or_cookie_hooksandtest_browser_session_gate_fails_when_has_session_returns_falseboth pass; assertbrowser.is_authenticated.assert_not_called().- Full cli-tools-common suite: 357 passed.
cj auth statusandcj relationships apply --dry-run <id>agree end-to-end against a real saved session.
Recurrence Prevention: Two regression tests in cli-tools-common/tests/test_command_registry.py enforce the contract — the gate MUST use has_session and MUST NOT call is_authenticated when neither AUTH_STORAGE_KEY nor AUTH_COOKIE_PATTERNS is declared. The retired test_browser_session_gate_uses_live_check_without_storage_key enforced the buggy behavior and is now replaced. Any future change that adds an implicit live-check fallback inside _check_credentials will fail these tests immediately.
General rule: When two surfaces inspect the same on-disk state (auth status and the command dispatch gate; UI and API; cache and source of truth), they MUST use the same default check. Diverging defaults across "status" and "gate" code paths guarantees user-visible disagreement and produces "it says I'm logged in but the command says I'm not" bugs.
4. Positive site-specific selectors (auth, action buttons, status pills) rot whenever the target site refactors
Symptom: A CLI declares positive selectors targeting specific HTML attributes the target site ships today, and those selectors silently stop matching after the site refactors. Two manifestations seen so far:
- Auth probe (CJ, Bug 5):
AUTH_SUCCESS_SELECTOR = "a[href*='/member/publisher/']"reported "not authenticated" on a valid session.auth statussaid authenticated,auth login --forceshort-circuited, dashboard URL loaded without redirecting to/login, butDEBUG=1showed_check_auth: selector="..." visible=False. - Apply / action button (CJ, Bug 6):
_APPLY_SELECTORS = ("button[data-testid='apply-button']", "button[aria-label*='Apply' i]", "a[data-testid='apply-button']", "a[aria-label*='Apply' i]", "button:has-text('Apply to Program')", "button:has-text('Join Program')")— every one of the testid/aria patterns timed out at 4s because CJ never actually rendered those attributes on the live page. The legacy tuple was dead-letter from day one; only the text-match members ever stood a chance, and once CJ rebuilt the page they too disappeared.
The pattern is the same in both cases: a positive marker pinned to surface attributes that the target site actively iterates on. Marketing renames the menu, eng restructures the URL space, an A/B test ships a different shell, the testid scheme moves to a new component library — and the selector silently stops matching.
Cause: Positive site-specific markers — data-testid, aria-label*=, class*=, nav-link href*=, role + name combinations — target HTML the target site owns and changes. There is no contract between us and the site; we are pattern-matching against incidental DOM. Every refactor of theirs is a regression of ours.
Fix: Prefer selector strategies in this order of resilience, from most to least durable:
- Negative of a logged-out signal for auth probes. Declare
AUTH_LOGIN_FORM_SELECTORon the browser subclass — a selector targeting the login form's password input or<form action*=login>. Example:AUTH_LOGIN_FORM_SELECTOR = 'input[type="password"], input[name="password"], form[action*="login"], form#loginForm'. The shared_check_authruns the absence check between the URL-pattern check and the cookie check. When the login form is NOT visible on a non-login URL, the user is authenticated. Login forms either render or they don't — that signal is stable. - Stable semantic affordances (
<form action=...>,<input type="submit" value="...">,<a href="...">, ARIA roles with exactname=) — these are part of the site's accessibility contract and survive longer than presentational attributes. Note that the same control may switch between<button>and<input type="submit">across pages (CJ does this with "Apply to Program" vs "Accept and Apply") — match by visible label + role, not by tag. - Row/group-scoped text matches for action buttons. When you need a specific advertiser's / order's / item's button on a list page, do NOT use a flat
button:has-text("...")— there will be many. Pin the outer:has()to the row container by a stable class AND by a property anchor unique to the target row, e.g.div.adv-row:has(a[href*="advertiserIds=<id>"]) button:has-text("Apply to Program"). Two filters guarantee exactly one match; one filter is a flaky bet. data-testid/aria-label*=only when the site documents them as a stable contract (rare for third-party apps). Treat as fragile by default; review every appearance during PR.- Pure CSS class selectors are a last resort —
button.btn-primaryorclass*="apply"rots on every redesign.
Verification:
- Compliance tests:
cd cli-tools-common && UV_PROJECT_ENVIRONMENT=~/.cache/uv/project-envs/cli-tools-common-tests uv run pytest tests/test_browser_automation.py -k bug5 -v— 3 tests pin the new auth-probe priority order and the login-form-visible-vs-absent semantics. - Per-CLI: write a regression test that asserts the browser subclass declares
AUTH_LOGIN_FORM_SELECTORand does NOT re-declare any legacy positive nav-link selector. For action buttons, add a string-contract test on the locator-builder helper (assert the locator contains the row id + the action text + a row-class scope) so a future regression that loosens scope fails immediately. - End-to-end: a real command that exercises the selector (e.g.
cj relationships apply <id>for auth + action) succeeds against a live page.
Recurrence Prevention: Two layers of tests:
- Shared layer:
cli_tools_common/tests/test_browser_automation.pylocks the_check_authpriority order: whenAUTH_LOGIN_FORM_SELECTORis declared,_check_authreturns True when the form is absent and False when it is visible. - Per-CLI layer: every browser CLI's
tests/test_bugfixes.py(or equivalent) pins the declarative shape of its selectors. CJ'stest_bug5_cj_browser_uses_absence_of_login_form_checkandtest_bug6_apply_locator_is_row_scoped_and_text_matchedare the templates — the first asserts the absence of the legacy positive nav-link, the second asserts the apply locator contains the advertiser id, the action label, and the row-scope:has().
When auditing a new browser CLI during code review: reject positive nav-link AUTH_SUCCESS_SELECTOR values, reject data-testid / aria-label*= tuples used as the only identifiers for click targets, and require row-scope on any per-row click selector. These are tomorrow-bugs by construction.
General rule: A selector strategy is durable when its inputs cannot be silently changed by the site owner. Auth-state probes should test for the absence of a "logged out" signal (login form, redirect to /login), not the presence of a "logged in" signal (avatar, nav menu, dashboard widget). Action-button locators should be scoped to a row container by both a stable class AND a target-specific property (an anchor href, a row id), with a text match on the visible action label. Selectors built on presentational attributes (testid, class names, aria-label) are fragile and must be treated as known-rotting by construction.