@peeve/cli
v1.1.1
Published
The Peeve developer CLI — init, connect, verify and toggle the kill switch. Zero dependencies.
Maintainers
Readme
@peeve/cli
The peeve command — scaffold, connect, verify, and operate a Peeve
install from the terminal. Built entirely on Node's parseArgs (node:util);
zero third-party dependencies.
# run without installing
npx @peeve/cli <command>
# or install globally
npm install -g @peeve/cli
peeve <command>Requires Node 18+.
Commands
peeve init
Detects your framework, installs the widget, and writes peeve.config. Run it
in your project directory — init scans the project, figures out where the HTML
shell lives, injects the Peeve <script> there, and writes a .peeve config
recording what it did. No manual copy-paste.
peeve init --publishable-key pk_live_… # detect → inject → write .peeve
peeve init --publishable-key pk_live_… --dry-run # show WHICH file it would edit + the insertion
peeve init --framework next-app --key pk_live_… # override detection
peeve init --publishable-key pk_live_… --print # only print the snippet, write nothing (legacy)
peeve init # prompts for the key in a TTY, then confirms the edit| Flag | Description |
| --- | --- |
| --publishable-key <pk_…> | Your publishable key (pk_*). Alias: --key. Prompted if omitted in a TTY. If still missing (non-interactive), a clearly-marked pk_REPLACE_ME placeholder is inserted with a printed TODO. |
| --framework <name> | Override detection. One of: next-app, next-pages, react, vue, angular, sveltekit, nuxt, astro, html. |
| --out <dir> | Project directory to scan and write into (default: cwd). |
| --cancel-selector <css> | CSS selector for your cancel/downgrade control. Emits data-cancel-selector="…" on the <script> so Peeve auto-tags churn intent. Prompted in a TTY; skipped with --yes. |
| --dry-run | Report which file would be edited and show the insertion, without writing. |
| --print | Print the <script> snippet only; write nothing (the old behavior). |
| --yes, -y | Don't prompt; skip the edit confirmation and the cancel-selector prompt. |
How detection works
init reads package.json deps/devDeps and probes key files:
| Framework | Signals | Where the widget is injected |
| --- | --- | --- |
| Next.js (App Router) | next + app/layout.* (or src/app/layout.*) | inside the layout's <body> |
| Next.js (Pages Router) | next + pages/_document.* (or src/pages/_document.*) | inside _document's <body> |
| React + Vite / CRA | react (+ vite) + index.html / public/index.html | before </body> |
| Vue | vue + index.html | before </body> |
| Angular | @angular/core + src/index.html | before </body> |
| SvelteKit | @sveltejs/kit + src/app.html | before </body> |
| Nuxt | nuxt + nuxt.config.* | prints app.head.script config guidance (no HTML shell to edit) |
| Astro | astro + src/layouts/Layout.astro | inside the layout's <body> |
| Plain HTML / static | an index.html with no framework | before </body> |
| Unknown | none of the above | prints the snippet + guidance, edits nothing |
Safety
- Idempotent — if the target already contains the widget (its
srcor adata-publishable-key/data-workspace-key),initskips with an "already installed" message. Running it twice never double-inserts. - Preview + confirm —
initshows the exact file and the+line it will add, then asksy/Nin a TTY.--yes(or a non-interactive shell) skips the prompt;--dry-runshows everything and writes nothing. - Never clobbers — it inserts only at a real
<body>anchor. If the expected anchor isn't found (or the file can't be read), it does not force an edit — it falls back to printing the snippet + guidance. Before editing it writes a.baknext to the file (never overwriting an existing one — the next free.bak,.bak.1, …); assume your project is git-tracked too. - Stays inside your project —
initedits only a regular file that really lives under the project directory. A symlinked target (or one behind a symlinked parent) is refused, not followed, so cloning an untrusted repo and runninginitcan't write outside it. - Won't publish a secret — an
sk_…key is refused rather than written into the snippet. See Configuration.
The .peeve config it writes records { publishableKey, framework, target, cancelSelector? }
so verify and later runs know what was installed and where. It is written 0600, and
init adds it to your .gitignore — creating one if there isn't one, never duplicating
an existing entry, and leaving an explicit !.peeve alone if you'd rather track it. The
key it holds today is public by design, but the format permits a secretKey, so "not
committed" is the safer default for it.
After installing, init prints the billing-intent data-* attributes
so you can wire up passive churn / upgrade tracking on your own markup. In a TTY it
also offers to capture your cancel-button CSS selector and bake it into the snippet
as data-cancel-selector="…".
peeve connect
One-liner to add Peeve to the current project and point at the sign-in flow —
init plus a sign-in link.
peeve connect --publishable-key pk_live_…peeve verify
Check an install end to end: is the platform reachable, is the key valid, and (with
--site) is the widget <script> present on the page?
peeve verify --publishable-key pk_live_… --site https://app.acme.com| Flag | Description |
| --- | --- |
| --publishable-key <pk_…> | Key to test (pk_*). Alias: --key. Or from PEEVE_PUBLISHABLE_KEY / PEEVE_KEY / .peeve. A secret key is refused. |
| --site <url> | Fetch this page and check for the widget tag. |
peeve killswitch
Toggle the workspace kill switch. Requires a secret key (sk_*).
peeve killswitch --on --secret-key sk_live_… # stop Peeve on your site
peeve killswitch --off --secret-key sk_live_… # resume| Flag | Description |
| --- | --- |
| --secret-key <sk_…> | Your secret key (sk_*). Alias: --key. Or from PEEVE_SECRET_KEY / PEEVE_KEY / .peeve. |
| --on / --off | Trip (default) or clear the kill switch. |
Billing-intent attributes
Peeve reads a small set of data-* attributes off your own billing markup so it
can passively track pricing views and cancel intent — and attach which plan, price,
and interval each signal belongs to — even when the assistant is never opened. That's
how a team sees who's about to churn and correlate reached-out-vs-cold.
The widget script tag is the same one peeve init prints; these attributes go on the
page elements you already render.
| Attribute | Put it on | What it does |
| --- | --- | --- |
| data-peeve-cancel | Your cancel / downgrade button or link | Its presence marks a cancel/downgrade control → emits a churn cancel_intent signal. |
| data-peeve-plan="<name>" | A pricing-plan card | Plan display name, e.g. "Growth". |
| data-peeve-amount="<amount>" | A pricing-plan card | Price, e.g. "99" or "$99.00". |
| data-peeve-currency="<code>" | A pricing-plan card | Optional ISO 4217 code for the amount (USD/EUR/GBP…). Inferred from the amount's currency symbol when present, else defaults to USD. Only meaningful alongside an amount. |
| data-peeve-interval="<monthly\|quarterly\|yearly>" | A pricing-plan card | Billing cycle. Use yearly for annual. |
Copy-paste — annotate a cancel button and a pricing plan card:
<!-- Cancel / downgrade control → emits a churn `cancel_intent` signal. -->
<button data-peeve-cancel>Cancel subscription</button>
<!-- Pricing-plan card → tags pricing views with plan / amount / interval. -->
<div data-peeve-plan="Growth"
data-peeve-amount="$99.00"
data-peeve-currency="USD"
data-peeve-interval="monthly">
…
</div>Why: passive churn / upgrade tracking — Peeve links each pricing view and cancel click to a plan, price, and interval with no extra event code.
Normalization. Peeve normalizes every data-peeve-* value before storing it — it
never keeps your raw string — so the data is consistent across apps: amount → a
number (currency symbols and thousands separators stripped), currency → an
uppercased ISO code, interval → canonical monthly | quarterly | yearly, and
plan → trimmed.
Prefer not to hand-edit the cancel button? Pass its CSS selector to init and the widget
tags it for you (no markup change needed):
peeve init --publishable-key pk_live_… --cancel-selector "#cancel-plan"
# → <script … data-cancel-selector="#cancel-plan" async></script>peeve init prints the annotation example above after it scaffolds, and (in a TTY) offers
to capture the selector interactively.
Configuration
Peeve has two kinds of key, and the CLI keeps them apart on purpose:
pk_…publishable — public by design. It ships inside your page HTML to every visitor. Used byinit,connectandverify.sk_…secret — a backend credential. Used bykillswitchonly.
Every command resolves the key of the kind it can safely use, in this order:
| | init / connect / verify (publishable) | killswitch (secret) |
| --- | --- | --- |
| 1. Flag | --publishable-key (or --key) | --secret-key (or --key) |
| 2. Environment | PEEVE_PUBLISHABLE_KEY, then PEEVE_KEY | PEEVE_SECRET_KEY, then PEEVE_KEY |
| 3. Config file | .peeve → publishableKey | .peeve → secretKey |
PEEVE_KEY stays the catch-all, but is only accepted when its prefix matches the kind
being asked for — so a PEEVE_KEY=sk_live_… left exported from a killswitch run is
never picked up by init and baked into your HTML. init, connect and verify
refuse an sk_… key outright rather than exposing it.
The .peeve file is JSON ({ "publishableKey": "pk_…" }; init also stores
"framework", "target" — the file it edited — and "cancelSelector" when you provide
one). init writes it 0600 (owner-only). Prefer PEEVE_SECRET_KEY over putting a
secretKey in .peeve, so it can't be committed.
The Peeve platform URLs are fixed — there's no base-URL flag or env var to set.
Each command talks to the host that serves what it needs: verify probes the
widget endpoints, and killswitch is a privileged backend call. Global flags:
-h/--help, -v/--version.
Examples
# scaffold, print only
npx @peeve/cli init --publishable-key pk_live_abc --print
# verify a live install
npx @peeve/cli verify --publishable-key pk_live_abc --site https://app.acme.com
# emergency stop (secret key)
PEEVE_SECRET_KEY=sk_live_xyz npx @peeve/cli killswitch --onNetwork — the hosts this CLI contacts
| Host | Used by | Why this host |
|---|---|---|
| live.peeve.ai | peeve verify | It probes the same publishable-key, Origin-gated endpoints a visitor's browser calls — which is what makes the check faithful rather than a stand-in. A 403 from here means the key is valid and this origin simply isn't allowlisted, which is the expected result from a terminal. |
| p.peeve.ai | peeve killswitch | A privileged backend call needing a secret key. It has no equivalent on the documented API, so it is served from the packages host. |
| app.peeve.ai | peeve connect | Printed as a sign-in link for you to open. Nothing is fetched from it. |
| cdn.peeve.ai | the snippet peeve init writes | Where widget.js is served from, on your visitors' pages. |
All of these are fixed — there is no base-URL flag, env var or constructor option to change them, by design. Which host a given call uses follows from what that call is, not from configuration.
If you run an egress allow-list or a strict CSP, these are the hostnames to permit.
License
MIT © Peeve
