diff --git a/.github/assets/hero-dark.png b/.github/assets/hero-dark.png index 88ccf87..0f17e7a 100644 Binary files a/.github/assets/hero-dark.png and b/.github/assets/hero-dark.png differ diff --git a/.github/assets/hero-light.png b/.github/assets/hero-light.png index 9724c12..c0c4891 100644 Binary files a/.github/assets/hero-light.png and b/.github/assets/hero-light.png differ diff --git a/README.md b/README.md index 7d6b555..16f9f80 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@

reins.tech · - docs + docs · + changelog

@@ -25,112 +26,49 @@

-reins gives agents (Claude Code, Cursor, Codex, Copilot, … — anything with a -shell) full control of your actual, logged-in Chromium browsers — Chrome, -Dia, Brave, Edge, Arc — through a CLI and a Manifest V3 extension. No MCP -server to register, no separate debug profile, no launch flags, no tokens: -install the CLI once, add the extension, install the skill, done. +reins gives your coding agent (Claude Code, Cursor, Codex, Copilot — anything +with a shell) control of the real, logged-in Chromium browser you already use, +through a CLI and a Manifest V3 extension. No MCP server to register, no debug +profile, no launch flags, no tokens. -## How it works - -``` -agent ── shell ──► reins CLI ── HTTP /rpc ──► reins daemon ◄── WS ── reins extension(s) - (auto-spawns the daemon) (127.0.0.1) │ chrome.debugger (CDP) - ▼ your tabs -``` - -- The **CLI** is the whole interface: `reins tabs`, `reins click`, - `reins screenshot`, … A **skill** teaches agents the commands. -- The **daemon** is invisible plumbing: any command starts it on demand; it - holds the WebSocket the extensions dial into (one daemon, any number of - browsers). `reins kill` stops it. -- The **extension** finds the daemon on its own (localhost port discovery) - and authenticates by its unforgeable `chrome-extension://` origin. - -## Install +## Quick start ```bash npm i -g @karnstack/reins # the CLI (daemon included, starts on demand) npx skills add karnstack/reins # the skill, into your agent(s) of choice -# then install the reins extension → it connects on its own ``` -Extension: **[Chrome Web Store](https://chromewebstore.google.com/detail/reins/hnjcfgochepemjndccfblpmfmlblkofo)** -— install it in every Chromium browser you want agents to reach. +Then install the extension in every Chromium browser you want agents to reach — +Chrome, Brave, Edge, Arc, Dia: -No store access, or store version unavailable? `reins extension` installs it -via Load unpacked instead — see [docs/SIDELOAD.md](docs/SIDELOAD.md). +**[Add reins from the Chrome Web Store](https://chromewebstore.google.com/detail/reins/hnjcfgochepemjndccfblpmfmlblkofo)** — it finds the local daemon and connects on its own. -That's the whole setup — no daemon to start, nothing to register per agent. -`reins status`, `reins browsers`, `reins tabs`, and `reins logs` show what's -connected; logs live in `~/.reins/logs/`. +No store access? `reins extension` installs it via Load unpacked instead — see +[docs/SIDELOAD.md](docs/SIDELOAD.md). That's the whole setup; `reins status` +shows what's connected. -## Commands +## The loop +```bash +reins snapshot # list interactive elements with refs → e5: button "Submit" +reins click --ref e5 # act by ref +reins text # verify (or: reins screenshot → prints an image path) ``` -Tabs & pages tabs · open · close · focus · nav -Interaction snapshot · click · type · fill · select · press · hover · - scroll · upload · wait · dialog · resize -Reading text · screenshot · console · network -Advanced eval '' · cdp ['{json}'] · daemon -Management browsers · status · allow · kill · doctor · logs · help -``` -The loop agents use: `reins snapshot` prints interactive elements with refs -(`e5: button "Submit"`) → act by ref (`reins click --ref e5`) → verify with -`reins text` or `reins screenshot` (prints an image path). Every command -takes `--tab ` (default: active tab), `--browser ` (only needed when -several browsers are connected — never guessed), and `--json`. - -`reins cdp` is the escape hatch to the full Chrome DevTools Protocol — -cookies, geolocation, PDF, tracing — anything the curated commands don't -wrap. - -## How it compares - -[agent-browser](https://github.com/vercel-labs/agent-browser) and -[dev3000](https://github.com/vercel-labs/dev3000) (Vercel Labs) and -[playwright-mcp](https://github.com/microsoft/playwright-mcp) (Microsoft) -live in the same neighborhood — browser tooling for coding agents — but -start from a different place: by default they launch and manage a browser -for the agent, while reins hands the agent the browser you already have -open. - -| | reins | agent-browser | dev3000 | playwright-mcp | -| --- | --- | --- | --- | --- | -| Built for | driving the browser you already use | general-purpose automation for agents | debugging your local dev server | browser automation as an MCP server | -| Browser | your real, running browsers — Chrome, Brave, Edge, Arc, Dia, all at once | its own Chrome for Testing it launches | its own monitored Chrome it launches | its own Playwright-managed browser — Chromium, Firefox, WebKit | -| Logged-in sessions | always — it *is* your profile | opt-in: reuse a profile's login state or attach to a running Chrome | per-project profile that persists between runs | its own persistent profile; real Chrome/Edge tabs via opt-in extension or CDP | -| Attaches via | MV3 extension + `chrome.debugger` — no launch flags, no open debug port | CDP from the outside | CDP from the outside | Playwright launch; opt-in extension or CDP endpoint | -| Agent interface | CLI + skill; nothing to register per agent | CLI, plus an optional MCP server | CLI + MCP server + unified timeline log | MCP server (stdio/HTTP), registered per client | -| Extras | raw CDP escape hatch (`reins cdp`) | HAR recording, request mocking, React tree, web vitals | server+browser timeline, error replay, `d3k fix` | isolated contexts, device emulation, vision/PDF caps, traces | - -**vs agent-browser** — agent-browser is a fast, general automation CLI that -owns its browser: it launches Chrome for Testing by default and reaches your -real login state only as an opt-in (profile reuse, or attaching to a Chrome -you started for it). reins starts from the opposite end: an extension inside -the browsers you already run means every session is authenticated by -definition, nothing new launches, and no debug port is ever exposed — the -daemon only accepts the extension's unforgeable origin on 127.0.0.1. If you -need headless fleets, request mocking, or CI runs, agent-browser is the -better fit; if the task is "act as me, in my browser", that's reins. - -**vs dev3000** — dev3000 solves a different problem: it wraps your dev -server, launches a monitored browser, and merges server logs, console, -network, and screenshots into one timeline an AI can debug from. It's -dev-loop observability, not general browser control. They compose: dev3000 -watches the app you're building, reins drives the rest of your browser — -dashboards, docs, the third-party service you're integrating. - -**vs playwright-mcp** — the closest comparison: its extension mode can also -drive existing tabs in your real browser (Chrome and Edge only). The -defaults differ. playwright-mcp launches a Playwright-managed browser with -its own persistent profile, and everything flows through an MCP server you -register in each client; reins is a plain CLI, so any agent with a shell -drives your everyday browsers with no per-agent setup, and one daemon -serves them all at once. Pick playwright-mcp for cross-engine coverage -(Firefox, WebKit), device emulation, or clean-room isolated sessions; pick -reins when the point is acting as you in the browser you live in. +Every command takes `--tab ` (default: active tab), `--browser ` (only +when several are connected), and `--json`. `reins help` is self-describing; +`reins cdp` is the escape hatch to the full Chrome DevTools Protocol. + +## Learn more + +The full story lives on the site: + +- **[Docs](https://reins.tech/docs)** — getting started, architecture, and the complete command reference +- **[How it compares](https://reins.tech/docs/comparison)** — vs agent-browser, dev3000, and playwright-mcp +- **[Security](https://reins.tech/docs/security)** — per-site permissions, `127.0.0.1`-only binding, and the threat model +- **[Site permissions](https://reins.tech/docs/permissions)** — the `deny` / `read` / `full` tiers and how to tighten them + +Report vulnerabilities privately via [GitHub security advisories](https://github.com/karnstack/reins/security/advisories/new). ## Develop @@ -140,58 +78,13 @@ pnpm install pnpm dev # watch-build all packages (extension → dist/) pnpm test # protocol + cli + extension unit/integration tests pnpm lint && pnpm typecheck && pnpm build -pnpm daemon # build + run the daemon in the foreground (Ctrl-C stops) pnpm reins tabs # build + run any CLI command -pnpm zip # package the extension for the Chrome Web Store ``` Local walkthrough (load unpacked, allow the dev ID, drive tabs): -**[docs/RUNNING.md](docs/RUNNING.md)**. Releasing: -**[docs/RELEASING.md](docs/RELEASING.md)** · -Chrome Web Store: **[docs/CHROME_WEB_STORE.md](docs/CHROME_WEB_STORE.md)**. - -## Packages - -- `packages/protocol` — shared zod frames + method schemas + port constants (`@reins/protocol`, private, bundled) -- `packages/cli` — CLI + daemon, published as [`@karnstack/reins`](https://www.npmjs.com/package/@karnstack/reins) (bin: `reins`) -- `packages/extension` — MV3 extension (Vite + crxjs) -- `packages/web` — landing page + docs at [reins.tech](https://reins.tech) (TanStack Start, prerendered, Cloudflare) -- `skills/reins` — the agent skill (`npx skills add karnstack/reins`) - -## Security - -- Per-site permissions, enforced in the extension: every host resolves to a - tier — `deny`, `read` (observation only: tabs/text/snapshot/screenshot/ - console/network), or `full`. Fresh installs default to `full` everywhere - (zero-config); tighten with `reins policy deny ` / - `reins policy readonly ` or from the popup. Loosening is popup-only - by design — a shell agent cannot grant itself access, because the check - runs in the extension, not the daemon. -- Everything binds `127.0.0.1` — nothing is reachable from the network. -- `/rpc` and the other endpoints validate the `Host` header (DNS-rebinding - protection), so web pages can't reach the daemon even via rebound DNS. -- The extension WebSocket is accepted only from exact allowlisted - `chrome-extension://` origins — browsers stamp that header themselves, - so pages and other extensions can't forge it. Dev builds are added with - `reins allow `. -- `chrome.debugger` shows the native "is being debugged" banner while a - command runs; the popup's **Disconnect** toggle is the kill switch. -- The extension collects nothing and talks to nothing but your local daemon — - see [docs/PRIVACY.md](docs/PRIVACY.md). -- The full threat model — what the tiers protect against, what they - deliberately don't (local processes, `cdp`'s browser-wide reach), prompt - injection, audit-trail limits, and a hardening checklist — lives in - [docs/SECURITY.md](docs/SECURITY.md). Vulnerabilities: report privately via - [GitHub security advisories](https://github.com/karnstack/reins/security/advisories/new). - -## Roadmap - -Where reins is headed — competitive landscape, a permission model, skill -evals, and capability gaps: **[docs/ROADMAP.md](docs/ROADMAP.md)**. - -## Design - -- [`docs/superpowers/specs/2026-07-04-reins-cli-skill-design.md`](docs/superpowers/specs/2026-07-04-reins-cli-skill-design.md) — CLI + daemon + extension, discovery, multi-browser, page control, skill +**[docs/RUNNING.md](docs/RUNNING.md)**. Releasing: **[docs/RELEASING.md](docs/RELEASING.md)**. + +Packages: `protocol` (shared zod frames), `cli` ([`@karnstack/reins`](https://www.npmjs.com/package/@karnstack/reins), bin `reins`), `extension` (MV3), `web` ([reins.tech](https://reins.tech)), and `skills/reins` (the agent skill). ## License diff --git a/packages/web/package.json b/packages/web/package.json index 838294d..0fbba31 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev --port 3000", - "build": "vite build", + "build": "vite build && pagefind --site dist/client", "preview": "vite preview", "typecheck": "tsc --noEmit", "deploy": "wrangler deploy" @@ -15,6 +15,7 @@ "@tanstack/react-start": "1.168.27", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^1.23.0", "radix-ui": "^1.6.1", "react": "19.2.7", @@ -28,6 +29,7 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.3", + "pagefind": "^1.5.2", "tailwindcss": "4.3.2", "typescript": "6.0.3", "vite": "8.1.3", diff --git a/packages/web/src/components/command-menu.tsx b/packages/web/src/components/command-menu.tsx new file mode 100644 index 0000000..665fada --- /dev/null +++ b/packages/web/src/components/command-menu.tsx @@ -0,0 +1,311 @@ +import { useRouter } from "@tanstack/react-router"; +import { + ArrowUpRight, + BookOpen, + Clipboard, + Download, + FileText, + GitCompare, + HelpCircle, + History, + Layers, + Lock, + Moon, + Shield, + Terminal, +} from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { GitHubIcon, NpmIcon, XIcon } from "@/components/icons"; +import { + CommandDialog, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { type DocResult, searchDocs } from "@/lib/pagefind"; +import { INSTALL_COMMAND, NPM_URL, REPO_URL, X_URL } from "@/lib/site"; + +interface Action { + id: string; + label: string; + keywords?: string; + icon: ReactNode; + run: () => void; +} + +interface CommandMenuProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const PAGES: Array<{ id: string; label: string; to: string; keywords?: string; icon: ReactNode }> = + [ + { + id: "docs", + label: "Getting started", + to: "/docs", + keywords: "docs overview install", + icon: , + }, + { + id: "sideload", + label: "Install without the store", + to: "/docs/sideload", + keywords: "unpacked load", + icon: , + }, + { + id: "commands", + label: "Commands", + to: "/docs/commands", + keywords: "cli reference snapshot click type eval cdp", + icon: , + }, + { + id: "permissions", + label: "Site permissions", + to: "/docs/permissions", + keywords: "policy deny readonly tiers", + icon: , + }, + { + id: "architecture", + label: "Architecture", + to: "/docs/architecture", + keywords: "daemon extension websocket", + icon: , + }, + { + id: "security", + label: "Security", + to: "/docs/security", + keywords: "threat model localhost", + icon: , + }, + { + id: "comparison", + label: "How it compares", + to: "/docs/comparison", + keywords: "vs alternatives cdp mcp", + icon: , + }, + { id: "faq", label: "FAQ", to: "/docs/faq", keywords: "questions", icon: }, + { + id: "changelog", + label: "Changelog", + to: "/changelog", + keywords: "releases versions notes", + icon: , + }, + { + id: "privacy", + label: "Privacy", + to: "/privacy", + keywords: "policy data telemetry", + icon: , + }, + ]; + +function matches(query: string, ...text: Array) { + const q = query.trim().toLowerCase(); + if (!q) return true; + return text.join(" ").toLowerCase().includes(q); +} + +export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { + const router = useRouter(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const reqId = useRef(0); + + // Reset when the dialog closes so it reopens clean. + useEffect(() => { + if (!open) { + setQuery(""); + setResults([]); + setLoading(false); + } + }, [open]); + + // Debounced full-text search; stale responses are dropped by request id. + useEffect(() => { + if (!open) return; + const q = query.trim(); + if (!q) { + setResults([]); + setLoading(false); + return; + } + const id = ++reqId.current; + setLoading(true); + const timer = setTimeout(() => { + searchDocs(q).then((r) => { + if (reqId.current === id) { + setResults(r); + setLoading(false); + } + }); + }, 120); + return () => clearTimeout(timer); + }, [query, open]); + + const close = () => onOpenChange(false); + const go = (to: string) => { + close(); + router.navigate({ to } as never); + }; + const openExternal = (url: string) => { + close(); + window.open(url, "_blank", "noreferrer"); + }; + + const actions: Action[] = [ + { + id: "theme", + label: "Toggle theme", + keywords: "dark light mode appearance", + icon: , + run: () => { + const el = document.documentElement; + const dark = el.classList.contains("dark"); + el.classList.toggle("dark", !dark); + try { + localStorage.setItem("theme", dark ? "light" : "dark"); + } catch { + /* storage blocked — theme still flips this session */ + } + close(); + }, + }, + { + id: "copy-install", + label: "Copy install command", + keywords: `npm install ${INSTALL_COMMAND}`, + icon: , + run: () => { + navigator.clipboard?.writeText(INSTALL_COMMAND).catch(() => {}); + close(); + }, + }, + ]; + + const links: Action[] = [ + { + id: "github", + label: "GitHub repository", + keywords: "source code", + icon: , + run: () => openExternal(REPO_URL), + }, + { + id: "npm", + label: "npm package", + keywords: "install package", + icon: , + run: () => openExternal(NPM_URL), + }, + { + id: "x", + label: "Karn on X", + keywords: "twitter gyankarn", + icon: , + run: () => openExternal(X_URL), + }, + ]; + + const q = query.trim(); + const showResults = q.length > 0; + const filteredPages = PAGES.filter((p) => matches(q, p.label, p.keywords)); + const filteredActions = actions.filter((a) => matches(q, a.label, a.keywords)); + const filteredLinks = links.filter((l) => matches(q, l.label, l.keywords)); + const nothing = + filteredPages.length === 0 && + filteredActions.length === 0 && + filteredLinks.length === 0 && + results.length === 0 && + !loading; + + return ( + + + + {nothing ? ( +

No results.

+ ) : null} + + {filteredPages.length > 0 ? ( + + {filteredPages.map((p) => ( + go(p.to)}> + {p.icon} + {p.label} + + ))} + + ) : null} + + {filteredActions.length > 0 ? ( + + {filteredActions.map((a) => ( + + {a.icon} + {a.label} + + ))} + + ) : null} + + {filteredLinks.length > 0 ? ( + + {filteredLinks.map((l) => ( + + {l.icon} + {l.label} + + ))} + + ) : null} + + {showResults && (loading || results.length > 0) ? ( + <> + {filteredPages.length + filteredActions.length + filteredLinks.length > 0 ? ( + + ) : null} + + {loading && results.length === 0 ? ( +

Searching…

+ ) : null} + {results.map((r) => ( + go(r.url)}> + + ))} +
+ + ) : null} + +
+ + to select + + + esc to close + +
+
+
+ ); +} diff --git a/packages/web/src/components/hero.tsx b/packages/web/src/components/hero.tsx index a7fae5d..51054eb 100644 --- a/packages/web/src/components/hero.tsx +++ b/packages/web/src/components/hero.tsx @@ -87,7 +87,7 @@ export function Hero() { aria-hidden="true" className="pointer-events-none absolute -top-10 left-1/2 -z-10 h-64 w-4/5 -translate-x-1/2 rounded-[50%] bg-primary/25 blur-[110px]" /> -
+
{/* Chrome bar */}
@@ -102,7 +102,7 @@ export function Hero() {
{/* Debugging banner (mirrors Chrome's native one) */} -
+
reins is controlling this browser
diff --git a/packages/web/src/components/icons.tsx b/packages/web/src/components/icons.tsx new file mode 100644 index 0000000..5e1f2e0 --- /dev/null +++ b/packages/web/src/components/icons.tsx @@ -0,0 +1,26 @@ +/* Brand glyphs that lucide doesn't ship. Sized by the caller via `className` + (e.g. `size-4`); colors inherit from `currentColor`. */ + +export function GitHubIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function XIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function NpmIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/packages/web/src/components/site-footer.tsx b/packages/web/src/components/site-footer.tsx index 5c51725..f028e6c 100644 --- a/packages/web/src/components/site-footer.tsx +++ b/packages/web/src/components/site-footer.tsx @@ -1,50 +1,155 @@ import { Link } from "@tanstack/react-router"; -import { LogoMark } from "@/components/logo"; +import type { ReactNode } from "react"; +import { GitHubIcon, NpmIcon, XIcon } from "@/components/icons"; +import { Wordmark } from "@/components/logo"; +import { GITHUB_URL, NPM_URL, REPO_URL, X_URL } from "@/lib/site"; + +const COLUMNS: Array<{ + title: string; + links: Array<{ label: string; to: string; external?: boolean }>; +}> = [ + { + title: "Product", + links: [ + { label: "Overview", to: "/" }, + { label: "Get started", to: "/docs" }, + { label: "Changelog", to: "/changelog" }, + ], + }, + { + title: "Docs", + links: [ + { label: "Commands", to: "/docs/commands" }, + { label: "Site permissions", to: "/docs/permissions" }, + { label: "Architecture", to: "/docs/architecture" }, + { label: "FAQ", to: "/docs/faq" }, + ], + }, + { + title: "Project", + links: [ + { label: "Security", to: "/docs/security" }, + { label: "Privacy", to: "/privacy" }, + { label: "GitHub", to: REPO_URL, external: true }, + { label: "npm", to: NPM_URL, external: true }, + ], + }, +]; + +function FooterLink({ + to, + external, + children, +}: { + to: string; + external?: boolean; + children: ReactNode; +}) { + const className = "text-sm font-normal text-muted-foreground hover:text-foreground"; + if (external) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +function SocialLink({ + href, + label, + children, +}: { + href: string; + label: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} export function SiteFooter() { return ( -