A stored key carried no record of which provider it belonged to. Options
saves {provider, model} without the key whenever the field is blank (which
it always is after a save, since it shows the encrypted badge instead), so
switching provider left the previous provider's key attached to the new one.
Every call then failed with that provider's own "Invalid API Key" while the
UI still showed a key as configured.
- types.ts: new `keyProvider` storage field, added to CONFIG_STORAGE_KEYS
- background.ts: keyProviderMismatch() guards the chat, COPY_AS and
stored-key LIST_MODELS paths; absent keyProvider (pre-upgrade) is allowed
- Options.tsx: stamps keyProvider on every save; drops the encrypted badge
and requires a new key when the saved one belongs to another provider or
is rejected; save-time guard messages are now actually rendered (they were
gated on modelsStatus === 'error' and never drew, so Save looked dead)
- providers.ts: providerLabel(); settings hint appended to 401/403 only;
listModels reports keyRejected and labels errors with the display name
- Anthropic: send anthropic-dangerous-direct-browser-access on the chat path
Docs: CLAUDE.md version-bump rule corrected — wxt.config.ts reads
pkg.version, so package.json is the only place to edit.
typecheck clean, 58/58 tests, build clean (281.72 kB, manifest 1.0.2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
CLAUDE.md — LexAI
Operating guide and control plane for Claude Code in this repo. Keep it durable and current, not a diary. Architecture/convention detail lives here and in
docs/.
0. Project contract
| Field | Value |
|---|---|
| Project | LexAI — Grammarly-like Chrome extension (Manifest V3), BYO-LLM-key |
| Outcome | Select text on any page → AI action (fix/rephrase/shorten/expand/explain) → Replace or Copy, with no backend and no subscription |
| Non-goals | No backend/account/subscription; no telemetry; no transmission of text/key except to the user's chosen provider; not a full editor |
| Primary user | People who hold an LLM API key and want inline writing help without a SaaS subscription |
| Acceptance tests | npm run typecheck + npm test -- --run pass; npm run build yields a loadable .output/chrome-mv3/; Replace works on textarea/input and contenteditable; key uses the encrypted path and is never logged/exfiltrated |
| Constraints | WXT ^0.20 + React 18 + TS; Node 22; Tailwind inactive (inline styles); <all_urls> today; Gitea CI + Chrome Web Store |
| Source of truth | This file + docs/; issue tracking in Plane (LEXAI) |
| Commands | install: npm install · test: npm test -- --run · typecheck: npm run typecheck · build: npm run build |
Definition of done
Done means: the change is implemented, typecheck/test/build pass, behavior is verified (for DOM/selection/replace changes, a real-page load-unpacked check — unit tests don't cover DOM timing), the codebase invariants in ## Lessons are preserved, and docs/HANDOFF.md states what changed and how it was tested. Do not claim success from code inspection alone.
1. Operating principles
- Evidence before inference. Read the relevant entrypoints/tests before changing them; report actual command output.
- Smallest useful diff. Be surgical — match existing style, don't reformat or broaden scope.
- Artifacts beat chat. Record state in
docs/(brief, decisions, tasks, handoff) so a fresh session resumes from files, not history. - Preserve the invariants. The message contract, snapshot-before-await,
data-lexaiguard, and key handling break silently — see## Lessons. - Use code for deterministic work. Prefer
typecheck/test/buildover reasoning about correctness. - Escalate intentionally. Surface anything touching manifest permissions, storage schema, the key path, or a release.
2. Files that preserve context
docs/
PROJECT_BRIEF.md # outcome, non-goals, constraints
ARCHITECTURE.md # the three-context system and boundaries
DECISIONS.md # ADRs: BYO-key, inline styles, no shadow DOM, key story, Gitea CI
TASKS.md # active contracts derived from RECOMMENDATIONS.md
MEMORY.md # curated durable knowledge; loaded every session; capped (60 lines)
EVALS.md # standing gates + failure-derived checks
LESSONS_LEARNED.md # codebase invariants (detail behind the ## Lessons list)
HANDOFF.md # current state, risks, next action
SELF_MODEL.md # operator/project model, kept honest by audit
attacksurface.md # exposure inventory (permissions, key storage, CI)
Supporting: README.md (public), PHASE1_SUMMARY.md (build history), RECOMMENDATIONS.md (source of the task list), src/utils/REFERENCE_NOTES.md (reference-project patterns).
What LexAI is
A Grammarly-like Chrome Extension (Manifest V3) providing AI writing assistance (grammar fix, rephrase, shorten, expand, explain) on any webpage. Users bring their own LLM API key — there is no LexAI backend. The service worker calls the user's chosen provider directly.
- Providers: OpenAI, Anthropic, Groq, OpenRouter (configured in
entrypoints/background.ts). - No subscription, no server. The API key lives encrypted in
chrome.storage.local.
Tech stack
- WXT
^0.20— extension framework (wraps Vite). Entrypoints inentrypoints/. - React 18 + TypeScript — Options and Popup pages only.
- Zustand — a dependency, but state is currently local; not yet wired into a store.
- tweetnacl / tweetnacl-util —
secretboxsymmetric encryption for the API key. - Vitest (jsdom) unit tests, Playwright e2e.
- Tailwind is in devDependencies but not active — all UI uses inline style objects (WXT PostCSS was never wired up). Do not assume Tailwind classes work.
Commands
npm install # first-time setup (node_modules gitignored; not present by default)
npm run dev # WXT dev server with hot reload
npm run build # production build → .output/chrome-mv3/
npm run zip # package for Chrome Web Store
npm test # vitest (add `-- --run` for one-shot, non-watch)
npm run test:e2e # Playwright (requires a prior `npm run build`)
npm run typecheck # tsc --noEmit
Prerequisite: Node. CI pins Node 22 (node:22-bookworm). Run npm install before any npm run * — the binaries (tsc, vitest) come from node_modules/.bin. Install triggers postinstall: wxt prepare, which generates .wxt/types/ — typecheck fails without it (import.meta.env is typed there). If a fresh clone errors with Property 'env' does not exist on type 'ImportMeta', run npx wxt prepare.
Load unpacked in Chrome: npm run build → chrome://extensions → Developer Mode → Load unpacked → select .output/chrome-mv3.
Architecture
Three cooperating contexts, message-passed over chrome.runtime:
entrypoints/content.ts (content script, injected into <all_urls>)
• Detects text selection: textarea/input (selectionStart/End) vs contenteditable/DOM (Range API)
• Renders the floating toolbar + result modal + toasts (inline-styled, appended to document.body)
• Snapshots selection state BEFORE any async call, then Replace uses the snapshot
• Sends { type: 'ANALYZE_TEXT', payload: {text, action, style} } to the background
entrypoints/background.ts (service worker — the LLM proxy)
• onMessage: ANALYZE_TEXT and COPY_AS
• Reads provider/apiKey/apiKeyEnc/encKey/model from chrome.storage.local
• Decrypts the key (tweetnacl secretbox), routes to the correct provider's fetch call
• Registers right-click context menus (action × style) on install
entrypoints/options/Options.tsx (settings page, React)
• Provider + model + API key form; encrypts the key and writes apiKeyEnc/encKey to storage
entrypoints/popup/Popup.tsx (toolbar popup, React)
• Standalone text box → same ANALYZE_TEXT flow; shows config status; links to Options
Content script and popup must not call provider APIs directly — CORS and key handling belong in the background service worker. Route everything through ANALYZE_TEXT/COPY_AS. See docs/ARCHITECTURE.md for the component table.
Message contract
ANALYZE_TEXTaccepts both{ payload: {text, action, style} }(content/popup) and flat{ text, action, style }. Keep both shapes working if you touch the handler.actionvalues:grammar,rephrase,shorten,expand,explain. The context menu and popup emitfix, whichgetSystemPromptnormalizes togrammar.- The listener returns
trueto keep the async channel open — required; removing it silently breaks every response.
Key conventions & gotchas
data-lexai="true"is set on every LexAI-injected DOM node. Selection/click handlers checktarget.closest('[data-lexai="true"]')to avoid self-triggering. Preserve it on any new injected element.- Selection is captured eagerly (on
mouseupand on buttonmousedown) because focus shifts and the live selection is gone by the time an async response returns. Keep the snapshot-before-await pattern intact. z-index: 2147483647(max) on toolbar/modal so they sit above host-page UI.- Provider code is duplicated: each provider has a
callX(system-prompt from action) and acallXWithPrompt(arbitrary system prompt, used by COPY_AS). A change to request shape usually needs both. This is a known smell — refactor tracked indocs/TASKS.md(T-04). - API-key handling: prefer the encrypted path (
apiKeyEnc+encKey); plaintextapiKeyis legacy/back-compat only. Never log the key. Never add code that transmits it anywhere except the user's chosen provider endpoint. - Backward compat: don't drop the plaintext
apiKeyfallback without a migration. - Console
[LexAI …]debug logs exist in content.ts's replace path — intentional for now, but should be gated behind a DEV flag before release (T-03).
Testing notes
tests/unit/setup.tsmocksglobal.chrome. Unit tests currently exercise storage mocks rather than importing the real handlers — seedocs/TASKS.mdT-08 for the gap.- Playwright e2e loads the built extension via
--load-extension=.output/chrome-mv3; the test files still contain[EXTENSION_ID]placeholders and won't pass as-is (T-09). - Standing gates and how to run them:
docs/EVALS.md.
CI / release (Gitea, not GitHub Actions)
Workflows live in .gitea/workflows/:
ci.yml— typecheck → test → build → publish zip to Gitea package registry (on push to main/develop, PRs).deploy-chrome.yml— onv*.*.*tag: build → upload → publish to Chrome Web Store.- Both send Telegram notifications. Secrets:
GITEATOKEN,CWS_*,TELEGRAM_*.
Version bumps: edit version in package.json only — wxt.config.ts reads pkg.version, so the manifest follows automatically (T-16 done). Use npm version <x.y.z> --no-git-tag-version so package-lock.json stays in sync. A v*.*.* git tag triggers the store deploy and publishes it live (deploy-chrome.yml:91).
Orchestration & agents
For coordinated work, run Fable as the main session (claude --agent fable-orchestrator) and let it route to specialists; see .claude/AGENTS.md for the roster. For LexAI code (entrypoints/, src/), prefer the lexai-extension-dev specialist over the generic builder — it knows the message contract, snapshot pattern, and key rules. Security-relevant changes (permissions, key path, dependencies) go through security-auditor.
On-demand skills in .claude/skills/: resume-project, memory-sync, continuous-improvement, dev-loop, attack-surface, prompt-injection-audit, self-model-audit. Invoke by name; keep each run bounded.
Quality gates by risk
| Risk | Examples in LexAI | Required gates |
|---|---|---|
| Low | docs, inline-style tweaks, copy | contract + self-check (typecheck) |
| Medium | new action, provider request change, Options/Popup UI | typecheck + test -- --run + build + a separate verifier |
| High | manifest permissions, key handling/storage schema, release/version, CWS listing | written plan + security-auditor + independent critic + real-page verification + explicit owner authorization before release |
When making changes
- After editing an entrypoint, run
npm run typecheckandnpm test -- --run. - For behavior changes,
npm run buildand load unpacked to verify in a real page — the selection/replace logic is DOM-timing-sensitive and unit tests don't cover it. - Keep UI styling inline (no Tailwind) unless you're intentionally wiring PostCSS.
Lessons
Codebase invariants that break silently when violated (detail + evidence in docs/LESSONS_LEARNED.md):
- Keep
return truein theonMessagelistener — else every async response is dropped. - Snapshot selection before any
await; handle textarea/input and contenteditable/Range paths. - Keep both
ANALYZE_TEXTshapes ({payload}and flat) and thefix→grammarnormalization. - Set
data-lexai="true"on every injected node; skip events onclosest('[data-lexai="true"]'). - Never log or transmit the API key except to the user's provider; keep the plaintext
apiKeyfallback until a migration exists. - Update both
callXandcallXWithPromptwhen changing a provider's request shape. - Never
fetcha provider from content/popup — route through the background worker. - Bump
versioninpackage.jsononly (+ lockfile); the manifest derives it viapkg.version. - Style inline; Tailwind classes do nothing until PostCSS is wired.
Memory protocol
Project knowledge lives in layers; write each item to exactly one and link instead of duplicating:
| Layer | Holds | Written when |
|---|---|---|
docs/MEMORY.md |
durable facts, conventions, environment quirks, key paths | a fresh agent would waste tokens rediscovering it |
docs/HANDOFF.md |
current state and next action only | end of every substantial task |
docs/DECISIONS.md |
why a choice was made | a hard-to-reverse choice is made |
docs/LESSONS_LEARNED.md + ## Lessons |
verified mistakes and their preventions | a learning signal is verified |
Context budget caps (session-start files are a recurring token tax): ## Lessons ≤ 12 rules, docs/MEMORY.md ≤ 60 entry lines, docs/HANDOFF.md ≤ 25 lines, docs/TASKS.md Active ≤ 7 contracts. When a cap is hit, run /memory-sync to consolidate/archive before adding — never grow past the cap.
State continuity
- On a fresh/compacted/interrupted session, invoke
/resume-projectbefore planning or editing. - Before ending a substantial task, update
docs/HANDOFF.md(verified state, changed paths, checks, risks, next action) and promote any durable new knowledge intodocs/MEMORY.mdper the memory protocol. - Run
/memory-syncat a phase change, before ending a long run, or when a capped file is full. - After a verified recurring mistake or workflow gap, invoke
/continuous-improvement; durable agent/skill changes go throughsystem-steward.