@peeve/wizard
v0.11.3
Published
The Peeve setup wizard — npx @peeve/wizard detects your framework and installs the Peeve widget/SDK with your publishable key, safely.
Downloads
399
Maintainers
Readme
@peeve/wizard
Install the Peeve widget/SDK into your app with one command —
modeled on @posthog/wizard. It detects your framework, injects the real Peeve
snippet with your publishable key in the right file, shows you a diff, backs up
everything it touches, and verifies the snippet landed.
npx @peeve/wizardTwo paths. A deterministic, offline, rules-based path covers the 80% case (Next.js, React + Vite, Vue, plain HTML). When your project is unusual — an unknown framework, or a known one whose entry file can't be edited safely — the wizard falls to the AI-assisted path: it sends the candidate file(s) to Peeve's backend, Claude returns the edit, and it lands through the exact same safety (diff, confirm,
.bakbackup). Browser device-login key fetch and a live first-ping poll are still a later phase.
What it does
- Requires a clean git tree so every change is reversible with
git checkout(override with--yes/--force; if it isn't a git repo it warns and still writes.bakbackups). - Detects your framework from
package.json+ key files. - Asks for your publishable key (
pk_live_…) — or takes--publishable-key. A secretsk_…key is rejected; it must never live in your app. - Injects the real Peeve snippet in the right place, writes the key to
.env.localas a public env var (never inlined, except plain HTML), shows a diff, asks for confirmation, and backs up every modified file to<file>.bak. Runs are idempotent — an existing Peeve snippet is detected and skipped, never duplicated. - Sets up the halves your project actually has — the browser widget where there's a UI, the server SDK where there's a real backend, both where there are both — and wires both identify calls: the server one where your app reads the session (not where it signs someone in), and the browser one where your pages know who is signed in.
- Offers to install
@peeve/sdkwith your own package manager, detected from your lockfile. It shows the exact command and asks separately — this is the only thing it executes. - Marks your pricing plans and cancel controls with Peeve's
data-peeve-*attributes — see billing annotations. - Verifies the snippet is present on disk and tells you exactly how Peeve confirms the first real ping (Widget → Verify in the dashboard).
- Ends with what's left for you — anything it printed instead of wiring, the secret key, an install you declined — repeated after the summary so it can't scroll away. See what's left for you.
Supported frameworks
| Framework | File it edits | What it inserts |
|---|---|---|
| Next.js App Router | app/layout.tsx | creates PeeveInit.tsx (a "use client" component that calls Peeve.init()), renders <PeeveInit /> in <body> |
| Next.js Pages Router | pages/_app.tsx | creates it if missing, else adds Peeve.init() in a useEffect |
| React + Vite | src/main.tsx | import { Peeve } from "@peeve/sdk" + Peeve.init() at startup |
| Vue | src/main.ts | Peeve.init() before mount() |
| Plain static HTML | index.html | <script src="…/widget.js" data-publishable-key … defer> before </body> |
Env vars written: NEXT_PUBLIC_PEEVE_PUBLISHABLE_KEY (Next), VITE_PEEVE_PUBLISHABLE_KEY
(Vite/Vue), or plain PEEVE_PUBLISHABLE_KEY for a backend with no browser framework to
prefix it. Force detection with --framework next-app|next-pages|react-vite|vue|html.
One env file per run. The publishable key and the secret key's name land in the
same file — .env.local, or .env when there's a bare Node server, which is the only
one dotenv reads by default and which every supported browser framework reads too.
They used to be chosen separately, by the browser framework and the server framework, so
a Vite front end with an Express backend could scatter them across two files, and a
backend-only project got no publishable key written anywhere at all.
The one command it runs
Adding @peeve/sdk to your package.json is the only thing the wizard needs to
execute rather than write. It detects your package manager from the lockfile
(package-lock.json → npm, pnpm-lock.yaml → pnpm, yarn.lock → yarn,
bun.lockb → bun; Corepack's packageManager field if there's no lockfile yet;
npm otherwise), shows you the exact command, and asks separately:
@peeve/sdk isn't installed here yet. (pnpm-lock.yaml → pnpm)
Run `pnpm add @peeve/sdk` now? › yes / no--yesdoes not answer this. Approving the edits this package authored is a different question from executing code on your machine right now — an install runs every lifecycle script in the dependency graph. Neither does a non-interactive run.--dry-runprints the command and runs nothing.- Declining is a complete outcome, not a failure: you get the command, and the
install lands when you run it. Same if the package manager errors, or isn't on your
PATH— a warning and the command, never a failed run. - It never writes your
package.json. The package manager owns that file, and the wizard refuses it as an edit target for good reason. - Skipped entirely when
@peeve/sdkis already a dependency (which is what makes a second run a no-op), and for a plain-HTML<script>install, which needs no package.
Server-side setup (@peeve/sdk/server)
The widget is only half of Peeve. The other half runs on your server: it identifies a signed-in user server-to-server and hands Peeve that user's API token for your own product — the credential the agent uses when it acts on their behalf, and the one thing that must never travel through a browser.
The wizard sets it up only where a backend really exists, because a
dependency is not evidence: every Next.js project depends on next, and most of
them have never written a route handler. It looks for app/api/, pages/api/,
server/, src/server/, route handlers (route.ts, +server.ts,
*.server.ts), or a server entry file, across Next.js, Nuxt, SvelteKit, Remix,
Express, Fastify and Hono.
So each project gets what it is:
| Project | Browser widget | Server SDK |
|---|:--:|:--:|
| Next.js with app/api/ | ✅ | ✅ |
| Next.js with no backend in it | ✅ | — |
| Vite SPA | ✅ | — |
| Express API with no UI | — | ✅ |
What it writes is a small module (lib/peeve.ts, server/utils/peeve.ts,
src/lib/server/peeve.ts, app/peeve.server.ts or src/peeve.ts, depending on
the framework) holding your PeeveServer client and a worked identify:
import { PeeveServer } from "@peeve/sdk/server";
export const peeve = new PeeveServer({ secretKey: process.env.PEEVE_SECRET_KEY! });
const u = await peeve.identify({ userId: user.id, token: user.apiToken });identify goes where the session is READ
A module nothing calls identifies nobody, so the wizard also finds the place that call belongs — and that place is not your sign-in handler. A sign-in is an event, and almost no request is that event: someone signs in on Monday and reloads your app every day that week. On every one of those loads they are signed in, no sign-in handler runs, and identify would never fire again.
The mapping Peeve needs is per-request state, so the call goes wherever your app resolves the current user — once per request, or once per render:
| Framework | Where the call goes | Why there |
|---|---|---|
| Next.js App Router | the root app/layout.tsx | a server component: it renders on the server for every request, and every route passes through it |
| Next.js Pages Router | there is no per-request server root — _app runs in the browser. The wizard says so and points at where the session really is resolved: getServerSideProps, your auth helper, or the auth callback | inventing a root that doesn't exist would be worse than naming the one that does |
| SvelteKit | hooks.server.ts → handle | every request runs through it, and it's where event.locals.user is populated |
| Nuxt | a server/middleware/ handler | Nuxt runs each of them on every request |
| Remix | the root loader in app/root.tsx | runs on every document request and every navigation |
| Express / Fastify / Hono | middleware or a preHandler hook, after your session middleware | that is when req.user exists |
Knowing the file is not knowing how your codebase gets the current user —
event.locals.user, await auth(), getServerSession(authOptions), a bespoke
cookie parser. Only your code knows, so the wizard asks Peeve to make the edit
(the same AI path, with the same
diff, confirmation and .bak backup) and, when it can't — no terminal, --yes,
--dry-run, an unreachable planner, or no safe edit — prints the exact file and
the exact call instead of guessing at your auth flow. Printing is a complete
outcome here, not a failure.
The generated identifyUser() is built to survive being called on every render:
it returns immediately when nobody is signed in, keeps one client per process,
collapses repeat calls for the same user into one network round trip, and never
throws — identifying a user is not worth failing someone's page load over. A
project that already calls identify is left alone, so a second run adds nothing.
The browser half: Peeve.identify()
identifyUser() is only one of the two. It is server-to-server and trusted: it
stores that user's token for your product. It tells the widget nothing —
the widget runs in a page your server never speaks to.
Peeve.identify({ userId, email, name }) is the other call, in the browser, and
it is deliberately untrusted. All it does is say who is looking. The widget
gates its /agent command on having a user id, and that id comes from this call
and nowhere else — so a workspace with a perfectly good stored token still shows
the agent to nobody until the page says who is there. Both are required; the
wizard wires both, in one pass.
It does not go next to Peeve.init(). The entry file the wizard already edits
(PeeveInit.tsx, src/main.tsx) is exactly the file with no session in scope —
that is why init() lives there. So the browser call goes where the user is:
your session hook or provider. Which file that is cannot be read off a path
(components/AuthProvider.tsx in one app, app/providers.tsx in the next), so
the wizard reads the files instead, and shapes the call for the library it finds:
| Library | Read as | The id |
|---|---|---|
| NextAuth / Auth.js | useSession() | session.user.id — only if your session callback adds it, and it must match the server's |
| Clerk | useUser() | user.id, with primaryEmailAddress and fullName |
| Auth0 | useUser() / useAuth0() | user.sub |
| Supabase Auth | onAuthStateChange() | user.id, name from user_metadata |
| Firebase Auth | onAuthStateChanged() | user.uid, name from displayName |
| your own hook / store | useAuth() | user.id |
The mapping is the part worth being exact about: a generic user.id example is
wrong for Auth0 and Firebase, and Peeve.identify({ userId: undefined })
identifies nobody while looking exactly like a working install.
The call is written as an effect (or the library's own subscription callback),
so it never changes what your component returns, returns early when nobody is
signed in, and is safe on every render. Alongside it the wizard leaves a commented
Peeve.setContext({ seats: 12, role: "admin" }) — the free-form business
context the agent sees. Its example never uses email, name, company,
phone, plan, createdAt, userId or token: those belong to identify()
and setContext drops them silently, so a first call using one looks like it
works and does nothing.
When the wizard can't find where your pages know who is signed in, it prints the exact call and says so — same posture as the server half, and it uploads nothing. A project with no sign of signed-in users at all is told the call is for when it has them, and nothing is listed as outstanding.
What's left for you
Every step that can't finish on its own explains itself where it happens — and then the run keeps going, and the explanation scrolls away. So the wizard ends by repeating them, after the summary, numbered, each with the file and the exact line:
3 things left for you
1. @peeve/sdk isn't installed here yet — the code the wizard wrote imports it.
Run npm install @peeve/sdk
2. The server half can't authenticate until PEEVE_SECRET_KEY has a value in .env.
File .env
Paste PEEVE_SECRET_KEY=sk_live_… (app.peeve.ai → Settings → API keys)
3. src/peeve.mjs was created, but nothing calls identifyUser() yet.
File src/server.js
Add await identifyUser(req.user)Entry 3 is the one this exists for: a module in your repo that nothing imports is worse than no module, because you can't tell why it's there. A run with nothing outstanding prints nothing extra.
The secret key stays yours
The wizard never accepts, prints, or writes a secret key. It writes the env
var name — PEEVE_SECRET_KEY=, with an empty value and a comment — into
.env.local (or .env for a bare Node server), and tells you to paste the value
in yourself. --publishable-key sk_… is still rejected outright, and if the env
file isn't gitignored the wizard says so before you paste anything into it.
Billing annotations
In the same run, the wizard looks for your pricing plans and subscription-cancel controls and marks them with the attributes Peeve reads:
| Attribute | Means |
|---|---|
| data-peeve-cancel | this is a cancel/downgrade control (presence only, no value) |
| data-peeve-plan="Growth" | the plan's name |
| data-peeve-amount="99" | the price exactly as your page shows it — never reformatted |
| data-peeve-interval="monthly" | monthly, quarterly or yearly |
| data-peeve-currency="EUR" | only when it isn't USD |
It finds the candidate files by path — pricing, plans, billing,
subscription, checkout, upgrade, settings, account — capped at 6 files,
skipping API routes, tests, and anything already carrying data-peeve-, which is
what makes a second run a no-op. The AI path
does the editing (pricing markup is written a thousand different ways), so every
annotated file gets its own diff, confirmation and .bak backup, exactly like
an install edit.
Finding nothing is the normal outcome for most projects. The wizard says so in one line and moves on — it never fails the run.
AI-assisted install (non-standard projects)
If the deterministic path can't classify your project — an unknown framework, or a known framework whose entry file it can't edit safely — the wizard asks Peeve's backend for help instead of giving up:
- It gathers up to 3 candidate files (the detected entry, then the usual
suspects:
app/layout.*,pages/_app.*,src/main.*,src/index.*,index.html, …), each capped to ~40 KB. - It sends them to Peeve, authenticated with your publishable key as a
bearer token — the same
pk_…the wizard already collects. No secret key is ever sent, and no source is stored server-side. The request carries the framework, the canonical Peeve snippet, the env-var name, and the candidate files. - Claude (via Peeve's Vercel AI Gateway) returns whole-file edits. The wizard
diffs each one against the file on disk, asks you to confirm, backs the file up
to
<file>.bak, and only then writes — identical safety to the deterministic path. It never applies an edit blind and never fabricates one. - If Peeve can't produce a safe edit (it returns
ok:truewith no edits), is unreachable, or errors, the wizard prints its reasoning and falls back to the copy-paste snippet — never worse than the manual flow, never a crash.
Use --ai to force this path even when deterministic detection would work.
Options
--publishable-key <pk_...> Your publishable key. Prompted in a TTY. (Alias: --key)
--framework <name> Force detection.
--ai Force the AI-assisted path (ask Peeve to plan the
install), even when deterministic detection would work.
--dir <path> Project directory to scan + edit (default: cwd).
--dry-run Show what would change; write nothing, run nothing.
--no-verify Skip the post-install on-disk check.
-y, --yes Don't prompt: proceed past a dirty tree and apply the
DETERMINISTIC install. Does NOT approve AI edits, and
does NOT run your package manager.
--force Proceed even if the git tree is dirty.
-h, --help Show help.
-v, --version Print the version.The Peeve platform URL is fixed — there's no base-URL flag or env var to set.
--yes does not approve AI edits
--yes applies the deterministic install without prompting, and --ai --yes is
refused outright. The two paths are not the same trust question:
- The deterministic path writes snippets this package authored — they live in
src/snippets.ts, readable before you ever run the wizard.--yesthere is a decision you can make in advance. - The AI path writes whole files authored by a remote service. The diff is the
only review they get, so a person has to see it. A security audit of this package
found two routes to code execution on a developer's machine that a returned edit
could take — a
.git/hooks/pre-commit, and apackage.jsonpostinstall. Both are refused outright now, but a denylist only covers what someone thought of; the person reading the diff covers the rest.
To review an AI plan without an interactive terminal, use --dry-run — it prints
the same diffs and writes nothing.
Safety model
- Git-clean precondition — refuses a dirty tree without
--yes/--force, and refuses just as firmly when it can't tell whether the tree is clean. - Diff + confirm — nothing is written without a shown diff and a yes. The diff escapes control characters, so file contents can't move the cursor or erase what you just read, and it's length-capped, so nothing scrolls the real change away.
- Backups — every file that already exists is copied to
<file>.bakbefore writing, and a second run never overwrites the first backup (.bak.1,.bak.2, …). - Additive & idempotent — only inserts imports / an init call / a render / a script tag; never rewrites unrelated code; never double-installs.
- Publishable key only —
pk_…is public by construction; a secretsk_…key is rejected outright and never written anywhere. The server half writes the env var NAME (PEEVE_SECRET_KEY=) and nothing else; the value is yours to paste. - Never worse than manual — for an unrecognized project it prints the exact snippet and the file to paste it into, rather than guessing. Anything it printed instead of wiring is repeated at the end, so a complete outcome is never one you had to scroll back to find.
- AI edits get the same gate — the AI path returns whole files, but each one is still diffed against disk, confirmed, and backed up before a byte is written, and the confirm prompt says the changes came from the planner.
- A returned path is treated as hostile input — it is resolved through symlinks
against the real filesystem (a symlinked directory inside the project does not
count as inside it), and anything that executes without you running your own app
is refused:
.git/,node_modules/,package.json,.npmrc,.husky/, CI and editor config. The response itself is size-capped, and two edits claiming the same path collapse to one so a later write can't contradict a diff you already approved. - Exactly one command is ever executed, and only if you say yes. Nothing the
wizard writes runs without you running your own app; the single exception is your
package manager installing
@peeve/sdk, which is asked and answered on its own. The exact command is shown first,--yesdoes not approve it, a non-interactive run does not either,--dry-runonly prints it, and declining leaves the command for you to run. It is spawned without a shell, with a fixed argument list, in your project directory — and it, not the wizard, is what writespackage.json.
What's deferred
- Browser device-login to fetch the workspace's publishable key automatically
(today you paste
pk_…or pass--publishable-key). - Live first-ping poll — watch the install signal and print a green check the moment the widget's first real page load reaches the platform.
Development
npm install
npm run build # tsc → dist/
npm run typecheck # tsc --noEmit
npm run smoke # build fixtures + real CLI runsLicense
MIT © Peeve.ai (owned by Veltron LLC)
