6.2 KiB
CLAUDE.md — LexAI
Guidance for Claude Code when working in this repository.
What LexAI is
A Grammarly-like Chrome Extension (Manifest V3) that provides 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 extension's service worker calls the user's chosen provider directly.
- Providers: OpenAI, Anthropic, Groq, OpenRouter (all 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 live inentrypoints/. - React 18 + TypeScript — used only for the Options and Popup pages.
- 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) for unit tests, Playwright for 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 is 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 * script — the binaries (tsc, vitest) come from node_modules/.bin.
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 (all 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.
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. When editing content.ts, 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 to be made in both. See "Recommendations" below — this is a known smell. - 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.
Testing notes
tests/unit/setup.tsmocksglobal.chrome. Unit tests currently exercise storage mocks rather than importing the real handlers — see Recommendations 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.
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: update version in both package.json and wxt.config.ts (the
manifest version comes from wxt.config.ts). A v*.*.* git tag triggers the store deploy.
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.