From 65f279edf208e90c195b6cda3653cadbd12b512e Mon Sep 17 00:00:00 2001 From: eze Date: Mon, 27 Jul 2026 12:19:49 -0300 Subject: [PATCH 1/6] docs: add CLI attribution design spec and implementation plan Records what the UTM approach can and cannot measure: auth-page arrival through OAuth completion, not command execution through completion. Documents two constraints found while verifying against posthog-js. Signup versus sign-in cannot ride on the UTM, because the URL is built before the user authenticates and the outcome does not exist yet; it has to be joined from the frontend signup event. And cookieless_mode on_reject drops events entirely before a consent decision and leaves no anon_distinct_id after a rejection, so the funnel is a lower bound on a consenting subset rather than a conversion rate. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-07-27-cli-utm-attribution.md | 571 ++++++++++++++++++ .../2026-07-27-cli-utm-attribution-design.md | 359 +++++++++++ 2 files changed, 930 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-cli-utm-attribution.md create mode 100644 docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md diff --git a/docs/superpowers/plans/2026-07-27-cli-utm-attribution.md b/docs/superpowers/plans/2026-07-27-cli-utm-attribution.md new file mode 100644 index 0000000..a0a45a8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-cli-utm-attribution.md @@ -0,0 +1,571 @@ +# CLI UTM Attribution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tag the OAuth authorization URL the CLI opens with standard UTM parameters so PostHog attributes web signups to `githits login` and `githits init`. + +**Architecture:** One pure helper module (`src/shared/utm.ts`) applies the five standard `utm_*` parameters to a URL. `login.ts` calls it once, immediately after `buildAuthUrl`, and uses the tagged value everywhere the URL is opened or printed. `init.ts` passes an `entrypoint` flag so the two commands are distinguishable. No new dependencies, no network calls, no changes to OAuth parameter construction. + +**Tech Stack:** TypeScript, Bun (`bun test`), Commander, existing `AuthService` DI. + +**Design spec:** `docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md` + +## Global Constraints + +- Parameter values are fixed and exact: `utm_source=githits-cli`, `utm_medium=cli`, `utm_campaign=cli-login` or `cli-init`, `utm_content=`. `utm_term` is left unset. +- Only the five standard `utm_*` parameters may be used. PostHog auto-captures exactly this list (`CAMPAIGN_PARAMS`); custom parameters are not captured. +- `withUtm` must never throw and must never alter OAuth parameters. An unparseable URL is returned unchanged. +- `withUtm` must be idempotent: a URL already carrying `utm_source` is returned unchanged. +- The CLI version comes from `import { version } from "../../package.json"` — the established pattern in `src/commands/` (see `doctor.ts:9`, `mcp.ts:7`). +- Run tests with `bun test`. Run `bun run build` before the final commit. +- Commit messages follow Conventional Commits with a body — no single-liners. +- This work is inert until the backend gate in the spec ("Blocking gate") is confirmed. Do not close the feature as delivered on the basis of these commits alone. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `src/shared/utm.ts` | **Create.** Pure URL tagging. No I/O, no imports. | +| `src/shared/utm.test.ts` | **Create.** Unit tests for the helper in isolation. | +| `src/shared/index.ts` | **Modify.** Barrel re-export of the new module. | +| `src/commands/login.ts` | **Modify.** Add `entrypoint` option; tag the URL once; use tagged value in all three consumption sites. | +| `src/commands/login.test.ts` | **Modify.** Assert tagging at the browser and printed-URL boundaries. | +| `src/commands/init/init.ts` | **Modify.** Pass `entrypoint: "init"`. | +| `src/commands/init/init.test.ts` | **Modify.** Assert the printed init URL carries `cli-init`. | +| `docs/implementation/auth.md` | **Modify.** Document the attribution behaviour and its scope limits. | + +--- + +### Task 1: The `withUtm` helper + +**Files:** +- Create: `src/shared/utm.ts` +- Create: `src/shared/utm.test.ts` +- Modify: `src/shared/index.ts:209` (append after the `spinner-messages.js` export) + +**Interfaces:** +- Consumes: nothing. +- Produces: `withUtm(url: string, ctx: UtmContext): string` and `interface UtmContext { campaign: string; content?: string }`. Task 2 depends on both names exactly as written. + +- [ ] **Step 1: Write the failing test** + +Create `src/shared/utm.test.ts`: + +```typescript +import { describe, expect, it } from "bun:test"; +import { withUtm } from "./utm.js"; + +describe("withUtm", () => { + it("sets source, medium, campaign and content", () => { + const result = withUtm("https://accounts.githits.com/oauth/authorize", { + campaign: "cli-login", + content: "1.4.2", + }); + + const params = new URL(result).searchParams; + expect(params.get("utm_source")).toBe("githits-cli"); + expect(params.get("utm_medium")).toBe("cli"); + expect(params.get("utm_campaign")).toBe("cli-login"); + expect(params.get("utm_content")).toBe("1.4.2"); + }); + + it("preserves existing query parameters", () => { + const result = withUtm( + "https://accounts.githits.com/oauth/authorize?response_type=code&state=abc", + { campaign: "cli-init" }, + ); + + const params = new URL(result).searchParams; + expect(params.get("response_type")).toBe("code"); + expect(params.get("state")).toBe("abc"); + expect(params.get("utm_campaign")).toBe("cli-init"); + }); + + it("returns the url untouched when it already carries utm_source", () => { + const url = "https://accounts.githits.com/oauth/authorize?utm_source=newsletter"; + + expect(withUtm(url, { campaign: "cli-login" })).toBe(url); + }); + + it("returns the url untouched when it does not parse", () => { + expect(withUtm("not a url", { campaign: "cli-login" })).toBe("not a url"); + }); + + it("omits utm_content when content is absent", () => { + const result = withUtm("https://accounts.githits.com/oauth/authorize", { + campaign: "cli-login", + }); + + expect(new URL(result).searchParams.has("utm_content")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/shared/utm.test.ts` +Expected: FAIL — module `./utm.js` cannot be resolved. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/shared/utm.ts`: + +```typescript +/** + * Campaign attribution for URLs the CLI sends users to. + * + * Only the five standard `utm_*` parameters are used, because PostHog + * auto-captures exactly that set (`CAMPAIGN_PARAMS` in posthog-js); any custom + * parameter would need explicit handling on the web side. + * + * This tags arrival at the auth page, not command execution. See + * `docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md`. + */ + +/** Attribution context for a single outbound URL. */ +export interface UtmContext { + /** Which command opened the URL: `"cli-login"` or `"cli-init"`. */ + campaign: string; + /** CLI version, used to spot conversion regressions across releases. */ + content?: string; +} + +const UTM_SOURCE = "githits-cli"; +const UTM_MEDIUM = "cli"; + +/** + * Return `url` tagged with the CLI's UTM parameters. + * + * Idempotent: a URL that already carries `utm_source` is returned unchanged so + * pre-existing attribution is never clobbered. Fail-safe: a URL that does not + * parse is returned unchanged rather than throwing, because attribution must + * never be able to break sign-in. + */ +export function withUtm(url: string, ctx: UtmContext): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + + if (parsed.searchParams.has("utm_source")) return url; + + parsed.searchParams.set("utm_source", UTM_SOURCE); + parsed.searchParams.set("utm_medium", UTM_MEDIUM); + parsed.searchParams.set("utm_campaign", ctx.campaign); + if (ctx.content) { + parsed.searchParams.set("utm_content", ctx.content); + } + + return parsed.toString(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/shared/utm.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Export from the shared barrel** + +Append to `src/shared/index.ts` after line 209 (`export { SPINNER_MESSAGES } from "./spinner-messages.js";`): + +```typescript +export { type UtmContext, withUtm } from "./utm.js"; +``` + +- [ ] **Step 6: Run the full suite to confirm the new export breaks nothing** + +Run: `bun test` +Expected: PASS, no new failures. + +- [ ] **Step 7: Commit** + +```bash +git add src/shared/utm.ts src/shared/utm.test.ts src/shared/index.ts +git commit -m "feat: add withUtm helper for CLI campaign attribution + +Pure URL tagging with the five standard utm_* parameters, which is the +exact set posthog-js auto-captures. Idempotent so pre-existing +attribution is never overwritten, and fail-safe on unparseable input so +attribution can never break sign-in." +``` + +--- + +### Task 2: Tag the authorization URL in `loginFlow` + +**Files:** +- Modify: `src/commands/login.ts` — imports, `LoginOptions` (line 18-20), the auth URL block (lines 221-255) +- Modify: `src/commands/login.test.ts` — append a new `describe` block + +**Interfaces:** +- Consumes: `withUtm`, `UtmContext` from Task 1. +- Produces: `LoginOptions.entrypoint?: "login" | "init"`. Task 3 sets this field. + +**Why all three sites:** `authUrl` is consumed in three places — printed under `--browser false` (line 243), passed to `browserService.open` (line 248), and printed as the browser-failed fallback (line 254). Tagging only the browser path would drop attribution for SSH users and for anyone whose browser failed to launch, biasing the funnel toward looking better than it is. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/commands/login.test.ts`: + +```typescript +describe("loginFlow UTM attribution", () => { + const mcpUrl = "https://mcp.githits.com"; + + it("tags the opened url with cli-login by default", async () => { + const authService = createMockAuthService(); + const authStorage = createMockAuthStorage(); + const browserService = createMockBrowserService(); + + await loginFlow( + { port: 8080 }, + { authService, authStorage, browserService, mcpUrl }, + silentLoginOutput, + ); + + const openedUrl = String(browserService.open.mock.calls[0][0]); + const params = new URL(openedUrl).searchParams; + expect(params.get("utm_source")).toBe("githits-cli"); + expect(params.get("utm_medium")).toBe("cli"); + expect(params.get("utm_campaign")).toBe("cli-login"); + expect(params.get("utm_content")).toBe(version); + }); + + it("tags the opened url with cli-init when entrypoint is init", async () => { + const authService = createMockAuthService(); + const authStorage = createMockAuthStorage(); + const browserService = createMockBrowserService(); + + await loginFlow( + { port: 8080, entrypoint: "init" }, + { authService, authStorage, browserService, mcpUrl }, + silentLoginOutput, + ); + + const openedUrl = String(browserService.open.mock.calls[0][0]); + expect(new URL(openedUrl).searchParams.get("utm_campaign")).toBe("cli-init"); + }); + + it("leaves the oauth parameters intact", async () => { + const authService = createMockAuthService(); + const authStorage = createMockAuthStorage(); + const browserService = createMockBrowserService(); + + await loginFlow( + { port: 8080 }, + { authService, authStorage, browserService, mcpUrl }, + silentLoginOutput, + ); + + const params = new URL( + String(browserService.open.mock.calls[0][0]), + ).searchParams; + expect(params.get("response_type")).toBe("code"); + expect(params.get("state")).toBe("test-state"); + expect(params.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback"); + expect(params.get("code_challenge")).toBeTruthy(); + }); + + it("tags the printed url when the browser is not used", async () => { + const authService = createMockAuthService(); + const authStorage = createMockAuthStorage(); + const browserService = createMockBrowserService(); + const written: string[] = []; + + await loginFlow( + { port: 8080, browser: false }, + { authService, authStorage, browserService, mcpUrl }, + { + write: (message: string) => { + written.push(message); + }, + }, + ); + + expect(browserService.open).not.toHaveBeenCalled(); + expect(written.join("")).toContain("utm_campaign=cli-login"); + }); +}); +``` + +Add `version` to the test file's imports, at the top of `src/commands/login.test.ts`: + +```typescript +import { version } from "../../package.json"; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/commands/login.test.ts` +Expected: FAIL — `utm_source` is null, and `entrypoint` is not a known property of `LoginOptions`. + +- [ ] **Step 3: Add the imports and the option** + +In `src/commands/login.ts`, add after the existing import block (after line 16): + +```typescript +import { version } from "../../package.json"; +import { withUtm } from "../shared/utm.js"; +``` + +Replace the `LoginOptions` interface (lines 18-20): + +```typescript +export interface LoginOptions extends OAuthCallbackOptions { + force?: boolean; + /** + * Which command initiated this flow. Drives `utm_campaign` so `login` and + * `init` traffic can be told apart in PostHog. Defaults to `"login"`. + */ + entrypoint?: "login" | "init"; +} +``` + +- [ ] **Step 4: Tag the URL and use it at all three sites** + +In `src/commands/login.ts`, immediately after the `const authUrl = authService.buildAuthUrl({ ... });` call (which ends at line 227), insert: + +```typescript + // Attribution rides on the URL itself: the CLI sends no events of its own. + // Tag once here so the browser open and both printed forms stay consistent. + const trackedAuthUrl = withUtm(authUrl, { + campaign: `cli-${options.entrypoint ?? "login"}`, + content: version, + }); +``` + +Then in the browser block (lines 241-255), replace all three `authUrl` references with `trackedAuthUrl`: + +```typescript + if (options.browser === false) { + output.write("Open this URL in your browser:\n"); + output.write(` ${trackedAuthUrl}\n`); + output.write(formatRemoteCallbackInstructions(port)); + } else { + output.write("Opening browser for GitHits sign-in...\n"); + try { + await browserService.open(trackedAuthUrl); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + output.write(`Could not open browser automatically: ${msg}\n`); + } + output.write("If the browser did not open, open this URL:\n"); + output.write(` ${trackedAuthUrl}\n`); + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test src/commands/login.test.ts` +Expected: PASS — the four new tests plus all pre-existing login tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/login.ts src/commands/login.test.ts +git commit -m "feat: tag the OAuth authorization URL with CLI attribution + +Applies utm_source/medium/campaign/content to the authorization URL once, +before it is opened or printed, so SSH and browser-failure paths carry the +same attribution as the happy path. Those are the segments most likely to +abandon, so tagging only the browser call would bias the funnel upward. + +Tagging sits above buildAuthUrl rather than inside it: OAuth parameters are +correctness-critical while attribution is best-effort, and the two should +not share a failure mode." +``` + +--- + +### Task 3: Distinguish `init` traffic + +**Files:** +- Modify: `src/commands/init/init.ts:30` (import) and `init.ts:2008-2011` (login options) +- Modify: `src/commands/init/init.test.ts` — append one test inside the same `describe` that holds "prints login URL instead of opening browser with --no-browser" (line 3316) + +**Interfaces:** +- Consumes: `LoginOptions.entrypoint` from Task 2. +- Produces: nothing. + +- [ ] **Step 1: Write the failing test** + +Append this test immediately after the existing test that ends at `src/commands/init/init.test.ts:3365`: + +```typescript + it("tags the printed login URL as cli-init", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const browserService = createMockBrowserService(); + const authService = createMockAuthService(); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + const createLoginDeps = mock(() => + Promise.resolve({ + authService, + authStorage: createMockAuthStorage(), + browserService, + mcpUrl: "https://mcp.githits.com", + }), + ); + + await initAction( + { browser: false, port: 8765 }, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps, + }, + ); + + const logCalls = getLogOutput(); + expect( + logCalls.some((msg) => msg.includes("utm_campaign=cli-init")), + ).toBe(true); + expect( + logCalls.some((msg) => msg.includes("utm_source=githits-cli")), + ).toBe(true); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/commands/init/init.test.ts -t "tags the printed login URL as cli-init"` +Expected: FAIL — the printed URL carries `utm_campaign=cli-login`, not `cli-init`. + +- [ ] **Step 3: Pass the entrypoint from init** + +In `src/commands/init/init.ts`, change the import on line 30 from: + +```typescript +import { loginFlow } from "../login.js"; +``` + +to: + +```typescript +import { loginFlow, type LoginOptions } from "../login.js"; +``` + +Then replace the `loginOptions` declaration at lines 2008-2011: + +```typescript + const loginOptions: LoginOptions = { + entrypoint: "init", + ...(options.browser !== undefined ? { browser: options.browser } : {}), + ...(options.port !== undefined ? { port: options.port } : {}), + }; +``` + +`LoginOptions` extends `OAuthCallbackOptions`, so the widened annotation stays +compatible with the `loginFlow` call below it. If `OAuthCallbackOptions` is now +unused in this file, remove it from its import to keep the linter quiet. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/commands/init/init.test.ts` +Expected: PASS — the new test plus all pre-existing init tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/init/init.ts src/commands/init/init.test.ts +git commit -m "feat: attribute init sign-ins separately from login + +Passes entrypoint: init so utm_campaign distinguishes the two commands. +The populations behave differently: someone running login already knows +what they want, while someone reaching auth through init is mid-IDE-setup +and far likelier to abandon. Without the split the funnel averages two +unlike groups into an unactionable number." +``` + +--- + +### Task 4: Document the behaviour and its limits + +**Files:** +- Modify: `docs/implementation/auth.md` — append a new section at the end + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +- [ ] **Step 1: Append the section** + +Add to the end of `docs/implementation/auth.md`: + +```markdown +## Campaign attribution + +The authorization URL is tagged with UTM parameters before it is opened or +printed (`src/commands/login.ts`, via `withUtm` in `src/shared/utm.ts`), so +PostHog attributes web signups to the CLI. + +| Parameter | Value | +| --- | --- | +| `utm_source` | `githits-cli` | +| `utm_medium` | `cli` | +| `utm_campaign` | `cli-login` or `cli-init` | +| `utm_content` | CLI version | + +Only the five standard `utm_*` parameters are used, because PostHog +auto-captures exactly that set. No analytics SDK is bundled and the CLI sends +no events of its own. + +### What this measures + +**Auth-page arrival → OAuth completion.** Not command execution → completion. + +A UTM parameter only exists once a browser requests the URL, so anyone who runs +the command and never reaches the auth page produces no signal and is absent +from the denominator. Numbers from this funnel are a lower bound on real +command-to-signup conversion, not a conversion rate. + +The web app additionally runs `posthog-js` with `cookieless_mode: "on_reject"`, +under which users who reject cookies have no `$anon_distinct_id` and therefore +cannot be linked to their signup, and users who never answer the cookie banner +have their events dropped entirely. Treat the funnel as directional and +comparative — `cli-init` vs `cli-login`, or movement across releases — rather +than absolute. + +Full analysis: `docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md`. +``` + +- [ ] **Step 2: Verify the build and the full suite** + +Run: `bun run build` +Expected: build succeeds with no type errors. + +Run: `bun test` +Expected: PASS, no failures. + +- [ ] **Step 3: Commit** + +```bash +git add docs/implementation/auth.md +git commit -m "docs: record CLI campaign attribution and its scope limits + +States plainly that the funnel measures auth-page arrival rather than +command execution, and that cookieless_mode: on_reject makes it a +directional lower bound, so the numbers are not read as a conversion rate." +``` + +--- + +## After implementation + +The CLI side is complete but **not yet measuring anything**. Confirm the backend +gate from the spec before treating this as delivered: + +1. The authorization endpoint preserves the query string through its redirect chain. +2. PostHog is initialised on the final auth page. +3. `posthog.identify()` runs on signup completion. + +Verification: run `githits login` against staging, follow the redirects, confirm +`utm_source=githits-cli` is still in the address bar at the final auth page, and +confirm the `$pageview` and `$identify` events in PostHog Live Events carry +`$utm_source`. diff --git a/docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md b/docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md new file mode 100644 index 0000000..7ee4690 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md @@ -0,0 +1,359 @@ +# CLI → Signup Attribution via UTM Parameters + +**Date:** 2026-07-27 +**Status:** Design approved, pending backend verification gate +**Scope:** `githits-cli` only (backend and web app changes tracked as external dependencies) + +## Goal + +Attribute GitHits web signups to the CLI, and measure how many people who reach +the auth page from a terminal go on to complete OAuth. + +## Scope — read this before interpreting any number + +This design measures **auth-page arrival → OAuth completion**. + +It does **not** measure **command execution → completion**. + +The distinction is not pedantic; it changes what the funnel means: + +| Stage | What happens | Measured here? | +| --- | --- | --- | +| 1. User runs `githits login` / `githits init` | CLI builds the auth URL, opens or prints it | **No** | +| 2. User lands on the auth page | Browser loads the URL carrying `utm_*` | Yes | +| 3. User completes OAuth | Callback returns, CLI stores tokens | Yes | + +Stage 1 is invisible by construction. A UTM parameter only exists once a browser +requests a URL. Anyone who runs the command and never reaches the auth page — +browser failed to open, the printed URL was ignored, `--no-browser` over SSH with +no follow-through, user changed their mind at the terminal — produces no signal +at all. + +That population is not merely unmeasured; it is **excluded from the denominator**. +A "90% conversion" from this funnel means 90% of people *who arrived at the auth +page*, not 90% of people who ran the command. Real command-to-signup conversion +is strictly lower by an unknown factor. + +Measuring stage 1 requires the CLI to emit an event from the user's machine. That +was explicitly rejected for this iteration (no phone-home, no new privacy posture, +no `posthog-node` dependency). If stage-1 drop-off becomes a question worth +answering, it is a separate design with a separate consent discussion. + +For reference, PostHog's own wizard reached the same conclusion: it emits a +per-step `wizard: step` event specifically "so we can build a step-level drop-off +funnel". UTMs were never the tool for that job. + +## Verified findings + +Verified against `posthog-js@1.404.1` and `PostHog/wizard@070d9daf` via GitHits. + +### PostHog captures the five standard UTMs automatically — CONFIRMED + +`packages/browser/src/utils/event-utils.ts:45`: + +```ts +export const CAMPAIGN_PARAMS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'gad_source', // google ads source + 'mc_cid', // mailchimp campaign id + ...PERSONAL_DATA_CAMPAIGN_PARAMS, +] +``` + +These land on events as `$utm_source`, `$utm_medium`, etc. PostHog also maintains +initial/session-scoped copies (`packages/browser/src/session-props.ts`). + +**Consequence: no manual `set_once` implementation is needed.** The web audit +found nothing that automatic capture fails to cover for consenting users. Any +custom parameter outside this list would *not* be captured automatically — which +is why the design confines itself to these five slots. + +### `identify()` links the anonymous visit to the signed-up user — CONFIRMED + +`packages/browser/src/posthog-core.ts:2594-2601`: + +```ts +this.capture( + EVENT_IDENTIFY, + { + distinct_id: new_distinct_id, + $anon_distinct_id: previous_distinct_id, + }, + { $set: userPropertiesToSet || {}, $set_once: userPropertiesToSetOnce || {} } +) +``` + +`$anon_distinct_id` carries the pre-identification id, which is what joins the +UTM-bearing pageview to the identified user. **This mechanism is the single point +of failure for the whole funnel** — see the cookieless limitation below. + +### Authorization endpoint preserves UTMs through the redirect — NOT VERIFIED + +GitHits indexes open-source code. The GitHits authorization endpoint is not +public, so this **cannot** be verified with the tools available here. It remains +an open manual gate (see Blocking gate). + +Two facts narrow it. First, the CLI hardcodes no login URL: it starts from +`DEFAULT_MCP_URL` (`https://mcp.githits.com`, `packages/core-internal/src/services/config.ts:10`), +calls `/.well-known/oauth-authorization-server`, and opens whatever +`authorization_endpoint` comes back — `accounts.githits.com/oauth/authorize` in +the test fixtures. Any proposal phrased as "change the hardcoded URL to include +UTMs" does not apply to this codebase; the parameters must be appended to the +discovered endpoint, which is what this design does. + +Second, per the team: the OAuth flow does traverse frontend pages, and the +frontend **already detects initial CLI install**. That existing detection is the +best evidence that a CLI-originated parameter survives to the frontend, and it +should be inspected before any new frontend work — if it already reads a URL +parameter, the UTMs should ride alongside that mechanism rather than duplicate it. + +### Signup vs sign-in cannot come from the UTM + +The URL is tagged *before* the user authenticates. At that moment it is not yet +known whether an account will be created — the fact does not exist. No URL +parameter can carry it. + +The split therefore comes from the frontend's own signup event, joined to the +UTM on the person at query time: + +```sql +SELECT person.properties.$initial_utm_source, + person.properties.$initial_utm_campaign, + count() AS signups +FROM events +WHERE event = 'user_signup' +GROUP BY 1, 2 +``` + +The CLI's contribution is the origin dimension only. This also means the locally +served success page (`successHtml`, `src/services/auth-service.ts:618`) is not a +usable signal: it renders identically for a new account and for a returning user +re-authenticating, and it is served by the CLI's own loopback server rather than +by any tracked property. + +## Limitation: `cookieless_mode: "on_reject"` + +The web app runs `posthog-js` with `cookieless_mode: "on_reject"`. Behaviour +verified in `packages/browser/src/__tests__/cookieless.test.ts`. + +This mode splits users into three populations with **materially different** +measurability: + +| Consent state | Pageview with UTMs | `$anon_distinct_id` | Joinable to signup? | +| --- | --- | --- | --- | +| Accepted cookies | Yes | uuidv7 | **Yes** | +| Rejected cookies | Yes, cookieless | `undefined` | **No** | +| No decision yet | **No — dropped** | n/a | **No** | + +### Before a consent decision, events are dropped, not buffered + +```ts +const { posthog, beforeSendMock } = await setup({ cookieless_mode: 'on_reject' }) +posthog.capture('eventBeforeOptIn') // will be dropped +expect(beforeSendMock).toBeCalledTimes(0) +expect(posthog.has_opted_out_capturing()).toEqual(true) +``` + +The instance starts opted-out-pending. One mitigating detail: the **initial +`$pageview` is re-emitted after opt-in**, so for users who eventually accept, the +UTMs are not lost by arriving before the banner was dismissed. + +### After rejection, there is no identity to stitch + +```ts +expect(event.properties.distinct_id).toEqual('$posthog_cookieless') +expect(event.properties.$anon_distinct_id).toEqual(undefined) +expect(event.properties.$device_id).toBe(null) +expect(event.properties.$cookieless_mode).toEqual(true) +``` + +`distinct_id` is the literal constant `'$posthog_cookieless'` — shared across every +cookieless user, so it identifies no one. `$anon_distinct_id` is `undefined`, so +`identify()` has nothing to link. For a user who rejects cookies, the UTM-bearing +pageview **can never be joined to their signup**, no matter what we send from the CLI. + +Switching consent also resets registered properties +(`"should reset when switching consent mode"`), so attribution registered before a +consent change does not survive it. + +### Why this bites CLI traffic harder than marketing traffic + +The population arriving here is mid-flow: a developer at a terminal, in a browser +window that just popped open, whose goal is to click "Authorize" and get back to +their shell. They are structurally *less* likely than a normal web visitor to stop +and engage with a cookie banner — which places a disproportionate share of exactly +our target population into the two unmeasurable rows. + +**Practical implication:** treat the resulting funnel as a **directional lower +bound on a consenting subset**, not as a conversion rate. It is fit for comparing +`cli-init` against `cli-login`, and for tracking movement across CLI releases, +because the consent bias applies roughly equally to both sides of those +comparisons. It is **not** fit for reporting an absolute signup conversion number. + +Quantifying the bias requires knowing the accept/reject/no-decision split on the +auth page. That is a web-side question and is out of scope here. + +## Design + +### New module: `src/shared/utm.ts` + +A pure function, no dependencies, unit-testable in isolation. Modelled on +`PostHog/wizard` `src/utils/links.ts:27-39`. + +```ts +export interface UtmContext { + /** Which command opened the URL: "cli-login" | "cli-init". */ + campaign: string; + /** CLI version. */ + content?: string; +} + +export function withUtm(url: string, ctx: UtmContext): string; +``` + +Behaviour: + +- Sets `utm_source=githits-cli` and `utm_medium=cli` unconditionally. +- Sets `utm_campaign` from `ctx.campaign`, `utm_content` from `ctx.content` when present. +- **Idempotent:** if the URL already carries `utm_source`, return it untouched. + Avoids clobbering attribution that arrived from elsewhere. +- **Fail-safe:** if the URL does not parse, return it untouched. Analytics must + never break sign-in. + +### Parameter mapping + +| Parameter | Value | Purpose | +| --- | --- | --- | +| `utm_source` | `githits-cli` | Separate CLI traffic from web/docs/other | +| `utm_medium` | `cli` | Group all terminal-originated traffic | +| `utm_campaign` | `cli-login` \| `cli-init` | The actionable segmentation | +| `utm_content` | CLI version (e.g. `1.4.2`) | Detect conversion regressions per release | +| `utm_term` | *(unused)* | Reserved for IDE/agent dimension, phase 2 | + +`utm_campaign` carries the command because we have no CLI-side event stream to +carry it. PostHog's wizard puts "which link" in `utm_content` and carries the +command as an analytics tag (`analytics.setTag('command', ...)`) — an option +unavailable to us by design. This mapping is the correct adaptation of that +pattern, not a deviation from it. + +### Host allowlisting: considered and rejected + +An earlier draft restricted tagging to GitHits hosts, mirroring the wizard's +`skipUtm` escape hatch. Rejected: the wizard needs it because it tags links to +docs and third-party sites, whereas the only URL we tag is the authorization +endpoint returned by OAuth discovery — the user's own auth server by construction. +An allowlist would silently break local, staging, and self-hosted setups while +preventing no real leak. + +### `src/commands/login.ts` + +`authUrl` is currently consumed in **three** places: + +- line 243 — printed under `--browser false` +- line 248 — passed to `browserService.open` +- line 254 — printed as the "if the browser did not open" fallback + +Tag **once**, immediately after `buildAuthUrl`, and use the tagged value in all +three. Tagging only the `browserService.open` path would silently drop attribution +for SSH users and for anyone whose browser failed to launch — precisely the +segments most likely to abandon, which would bias the funnel in the direction that +makes it look best. + +`LoginOptions` gains: + +```ts +/** Which command initiated this flow; drives utm_campaign. Defaults to "login". */ +entrypoint?: "login" | "init"; +``` + +Version comes from `import { version } from "../../package.json"`, the established +pattern in `src/commands/` (`doctor.ts:9`, `mcp.ts:7`). + +### `src/commands/init/init.ts` + +At the `loginFlow` call (line 2012), pass `entrypoint: "init"` alongside the +existing `browser` and `port` options. + +### Why not put this in `buildAuthUrl` + +`AuthServiceImpl.buildAuthUrl` (`src/services/auth-service.ts:282`) constructs the +OAuth request: `response_type`, `client_id`, `redirect_uri`, `state`, +`code_challenge`. Marketing attribution is an unrelated concern with a different +failure mode — OAuth params are correctness-critical, UTMs are best-effort. Keeping +them separate means a bug in attribution cannot produce a malformed OAuth request, +and it matches how the wizard layers tagging above its OAuth code. + +### Not used: the OAuth `state` parameter + +`state` is a CSRF defence. It already travels to the web and back, which makes it +superficially attractive as a correlation key, but routing a security parameter +into an analytics pipeline puts it in logs, dashboards, and exports. Not done. + +## Testing + +**`src/shared/utm.test.ts`** — pure unit tests: + +- Sets all four parameters from a clean URL. +- Preserves pre-existing query parameters (the OAuth params must survive). +- Idempotent: a URL already carrying `utm_source` is returned unchanged. +- Fail-safe: a malformed URL is returned unchanged rather than throwing. +- Omits `utm_content` when `content` is absent. + +**`src/commands/login.test.ts`** — behaviour at the boundary: + +- `browserService.open` receives a URL carrying the UTM parameters. +- Under `--no-browser`, the **printed** URL carries them too. +- `entrypoint: "init"` produces `utm_campaign=cli-init`; the default produces + `cli-login`. +- The OAuth parameters (`state`, `code_challenge`, `client_id`, `redirect_uri`) + are unchanged by tagging. + +## Blocking gate — outside this repo + +**The CLI change is inert until this is confirmed.** Verify before shipping: + +1. **The authorization endpoint preserves the query string through its redirect + chain.** If `/authorize` 302s to the sign-in/signup page and drops unknown query + parameters, the UTMs are destroyed in transit and nothing here measures anything. + This is the highest-risk item and it could not be verified with GitHits. +2. **PostHog is initialised on the final auth page** — the page that actually + renders after redirects, not only the marketing site. +3. **`posthog.identify()` runs on signup completion** with the user id, so + `$anon_distinct_id` links the anonymous UTM pageview to the account. + +Recommended verification, two minutes and no deploy required: + +``` +githits login --no-browser +``` + +Copy the printed URL, open it, and watch the address bar through the redirect +chain. If it passes through a frontend page with the `utm_*` parameters still +present, client-side capture works. If it jumps straight to +`github.com/login/oauth/authorize`, no instrumented page sees them and the +attribution has to be recorded server-side instead. + +Then confirm in PostHog Live Events that the `$pageview` and `$identify` events +carry `$utm_source`. + +## Out of scope + +- Stage-1 (command execution) measurement — needs CLI-side events and a consent decision. +- `utm_term` / IDE dimension — phase 2, and needs confirmation that agent selection + happens before the auth URL is built in `init`. +- UTM tagging of the other URLs the CLI prints (`cli.ts:116`, `mcp.ts:114`, + `auth-service.ts:931`) — help text and docs links, low value. +- Any change to `packages/core-internal/src/shared/telemetry.ts`. Despite the name, + it is a local stderr timing-span reporter behind `GITHITS_TELEMETRY`, unrelated to + product analytics, and it sends nothing anywhere. + +## References + +- `PostHog/wizard@070d9daf` `src/utils/links.ts:27-39` — `withUtm` (MIT) +- `posthog-js@1.404.1` `packages/browser/src/utils/event-utils.ts:45` — `CAMPAIGN_PARAMS` +- `posthog-js@1.404.1` `packages/browser/src/posthog-core.ts:2594-2601` — `$identify` / `$anon_distinct_id` +- `posthog-js@1.404.1` `packages/browser/src/__tests__/cookieless.test.ts` — `on_reject` behaviour From 833be545269daabed4d059e1f2c6d0cdcd45a615 Mon Sep 17 00:00:00 2001 From: eze Date: Mon, 27 Jul 2026 12:34:01 -0300 Subject: [PATCH 2/6] feat: add withUtm helper for CLI campaign attribution Pure URL tagging with the five standard utm_* parameters, which is the exact set posthog-js auto-captures in CAMPAIGN_PARAMS. A custom parameter name would need explicit handling on the web side, so the helper is confined to those slots. Idempotent so pre-existing attribution is never overwritten, and fail-safe on unparseable input so attribution can never break sign-in. Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/index.ts | 1 + src/shared/utm.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++ src/shared/utm.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/shared/utm.test.ts create mode 100644 src/shared/utm.ts diff --git a/src/shared/index.ts b/src/shared/index.ts index 20dde86..28ff78c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -207,3 +207,4 @@ export { } from "./root-cli-pre-action.js"; export { type Spinner, startSpinner } from "./spinner.js"; export { SPINNER_MESSAGES } from "./spinner-messages.js"; +export { type UtmContext, withUtm } from "./utm.js"; diff --git a/src/shared/utm.test.ts b/src/shared/utm.test.ts new file mode 100644 index 0000000..20672f7 --- /dev/null +++ b/src/shared/utm.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test"; +import { withUtm } from "./utm.js"; + +describe("withUtm", () => { + it("sets source, medium, campaign and content", () => { + const result = withUtm("https://accounts.githits.com/oauth/authorize", { + campaign: "cli-login", + content: "1.4.2", + }); + + const params = new URL(result).searchParams; + expect(params.get("utm_source")).toBe("githits-cli"); + expect(params.get("utm_medium")).toBe("cli"); + expect(params.get("utm_campaign")).toBe("cli-login"); + expect(params.get("utm_content")).toBe("1.4.2"); + }); + + it("preserves existing query parameters", () => { + const result = withUtm( + "https://accounts.githits.com/oauth/authorize?response_type=code&state=abc", + { campaign: "cli-init" }, + ); + + const params = new URL(result).searchParams; + expect(params.get("response_type")).toBe("code"); + expect(params.get("state")).toBe("abc"); + expect(params.get("utm_campaign")).toBe("cli-init"); + }); + + it("returns the url untouched when it already carries utm_source", () => { + const url = + "https://accounts.githits.com/oauth/authorize?utm_source=newsletter"; + + expect(withUtm(url, { campaign: "cli-login" })).toBe(url); + }); + + it("returns the url untouched when it does not parse", () => { + expect(withUtm("not a url", { campaign: "cli-login" })).toBe("not a url"); + }); + + it("omits utm_content when content is absent", () => { + const result = withUtm("https://accounts.githits.com/oauth/authorize", { + campaign: "cli-login", + }); + + expect(new URL(result).searchParams.has("utm_content")).toBe(false); + }); +}); diff --git a/src/shared/utm.ts b/src/shared/utm.ts new file mode 100644 index 0000000..89f1746 --- /dev/null +++ b/src/shared/utm.ts @@ -0,0 +1,49 @@ +/** + * Campaign attribution for URLs the CLI sends users to. + * + * Only the five standard `utm_*` parameters are used, because PostHog + * auto-captures exactly that set (`CAMPAIGN_PARAMS` in posthog-js); any custom + * parameter would need explicit handling on the web side. + * + * This tags arrival at the auth page, not command execution. See + * `docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md`. + */ + +/** Attribution context for a single outbound URL. */ +export interface UtmContext { + /** Which command opened the URL: `"cli-login"` or `"cli-init"`. */ + campaign: string; + /** CLI version, used to spot conversion regressions across releases. */ + content?: string; +} + +const UTM_SOURCE = "githits-cli"; +const UTM_MEDIUM = "cli"; + +/** + * Return `url` tagged with the CLI's UTM parameters. + * + * Idempotent: a URL that already carries `utm_source` is returned unchanged so + * pre-existing attribution is never clobbered. Fail-safe: a URL that does not + * parse is returned unchanged rather than throwing, because attribution must + * never be able to break sign-in. + */ +export function withUtm(url: string, ctx: UtmContext): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + + if (parsed.searchParams.has("utm_source")) return url; + + parsed.searchParams.set("utm_source", UTM_SOURCE); + parsed.searchParams.set("utm_medium", UTM_MEDIUM); + parsed.searchParams.set("utm_campaign", ctx.campaign); + if (ctx.content) { + parsed.searchParams.set("utm_content", ctx.content); + } + + return parsed.toString(); +} From beed622acf1a4a77b2e73290965dd24a84113e37 Mon Sep 17 00:00:00 2001 From: eze Date: Mon, 27 Jul 2026 12:38:31 -0300 Subject: [PATCH 3/6] feat: tag the OAuth authorization URL with CLI attribution Applies utm_source/medium/campaign/content to the authorization URL once, before it is opened or printed, so the SSH and browser-failure paths carry the same attribution as the happy path. Those are the segments most likely to abandon, so tagging only the browser call would bias the funnel upward. Tagging sits above buildAuthUrl rather than inside it: OAuth parameters are correctness-critical while attribution is best-effort, and the two should not share a failure mode. The CLI discovers its authorization endpoint via OAuth metadata rather than hardcoding a login URL, so the parameters are appended to whatever the discovery returns. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/login.test.ts | 110 +++++++++++++++++++++++++++++++++++++ src/commands/login.ts | 22 +++++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 48f4bc6..930681d 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, mock, spyOn } from "bun:test"; import { FetchTimeoutError } from "@githits/core-internal"; +import { version } from "../../package.json"; import { createMockAuthService, createMockAuthStorage, @@ -963,3 +964,112 @@ describe("loginFlow", () => { consoleSpy.mockRestore(); }); }); + +describe("loginFlow UTM attribution", () => { + const mcpUrl = "https://mcp.githits.com"; + + /** Browser mock that records the URL it was asked to open. */ + function createUrlCapturingBrowser(): { + browserService: ReturnType; + openedUrl: () => string; + } { + const openedUrls: string[] = []; + const browserService = createMockBrowserService({ + open: mock((url: string) => { + openedUrls.push(url); + return Promise.resolve(); + }), + }); + return { + browserService, + openedUrl: () => { + const [first] = openedUrls; + if (first === undefined) { + throw new Error("browser was never opened"); + } + return first; + }, + }; + } + + it("tags the opened url with cli-login by default", async () => { + const { browserService, openedUrl } = createUrlCapturingBrowser(); + + await loginFlow( + { port: 8080 }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService, + mcpUrl, + }, + silentLoginOutput, + ); + + const params = new URL(openedUrl()).searchParams; + expect(params.get("utm_source")).toBe("githits-cli"); + expect(params.get("utm_medium")).toBe("cli"); + expect(params.get("utm_campaign")).toBe("cli-login"); + expect(params.get("utm_content")).toBe(version); + }); + + it("tags the opened url with cli-init when entrypoint is init", async () => { + const { browserService, openedUrl } = createUrlCapturingBrowser(); + + await loginFlow( + { port: 8080, entrypoint: "init" }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService, + mcpUrl, + }, + silentLoginOutput, + ); + + expect(new URL(openedUrl()).searchParams.get("utm_campaign")).toBe( + "cli-init", + ); + }); + + it("leaves the oauth parameters intact", async () => { + const { browserService, openedUrl } = createUrlCapturingBrowser(); + + await loginFlow( + { port: 8080 }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService, + mcpUrl, + }, + silentLoginOutput, + ); + + const params = new URL(openedUrl()).searchParams; + expect(params.get("response_type")).toBe("code"); + expect(params.get("state")).toBe("test-state"); + expect(params.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback"); + expect(params.get("code_challenge")).toBeTruthy(); + }); + + it("tags the printed url when the browser is not used", async () => { + const authService = createMockAuthService(); + const authStorage = createMockAuthStorage(); + const browserService = createMockBrowserService(); + const written: string[] = []; + + await loginFlow( + { port: 8080, browser: false }, + { authService, authStorage, browserService, mcpUrl }, + { + write: (message: string) => { + written.push(message); + }, + }, + ); + + expect(browserService.open).not.toHaveBeenCalled(); + expect(written.join("")).toContain("utm_campaign=cli-login"); + }); +}); diff --git a/src/commands/login.ts b/src/commands/login.ts index 404bcec..37b944c 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -3,10 +3,12 @@ import { normalizeSingleLineText, } from "@githits/core-internal"; import type { Command } from "commander"; +import { version } from "../../package.json"; import { createAuthCommandDependencies } from "../container.js"; import type { AuthService } from "../services/auth-service.js"; import type { AuthStorage } from "../services/auth-storage.js"; import type { BrowserService } from "../services/browser-service.js"; +import { withUtm } from "../shared/utm.js"; import { addOAuthCallbackOptions, CALLBACK_PORT_REQUIREMENT, @@ -17,6 +19,11 @@ import { export interface LoginOptions extends OAuthCallbackOptions { force?: boolean; + /** + * Which command initiated this flow. Drives `utm_campaign` so `login` and + * `init` traffic can be told apart in PostHog. Defaults to `"login"`. + */ + entrypoint?: "login" | "init"; } /** Result of the login flow, used by init to handle outcomes without process.exit */ @@ -226,6 +233,15 @@ export async function loginFlow( codeChallenge: challenge, }); + // Attribution rides on the URL itself: the CLI sends no events of its own. + // Tag once here so the browser open and both printed forms stay consistent -- + // tagging only the browser path would drop attribution for SSH users and for + // anyone whose browser failed to launch. + const trackedAuthUrl = withUtm(authUrl, { + campaign: `cli-${options.entrypoint ?? "login"}`, + content: version, + }); + // Step 5: Start callback server let callbackServer: Awaited< ReturnType @@ -240,18 +256,18 @@ export async function loginFlow( // Step 6: Open browser or show URL if (options.browser === false) { output.write("Open this URL in your browser:\n"); - output.write(` ${authUrl}\n`); + output.write(` ${trackedAuthUrl}\n`); output.write(formatRemoteCallbackInstructions(port)); } else { output.write("Opening browser for GitHits sign-in...\n"); try { - await browserService.open(authUrl); + await browserService.open(trackedAuthUrl); } catch (error) { const msg = error instanceof Error ? error.message : String(error); output.write(`Could not open browser automatically: ${msg}\n`); } output.write("If the browser did not open, open this URL:\n"); - output.write(` ${authUrl}\n`); + output.write(` ${trackedAuthUrl}\n`); } output.write("Waiting for sign-in to finish...\n"); From d13923b5484be708031275b7c0abc3f80110a9ef Mon Sep 17 00:00:00 2001 From: eze Date: Mon, 27 Jul 2026 12:38:59 -0300 Subject: [PATCH 4/6] feat: attribute init sign-ins separately from login Passes entrypoint: init so utm_campaign distinguishes the two commands. The populations behave differently: someone running login already knows what they want, while someone reaching auth through init is mid-IDE-setup and far likelier to abandon. Without the split the funnel averages two unlike groups into an unactionable number. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/init/init.test.ts | 35 ++++++++++++++++++++++++++++++++++ src/commands/init/init.ts | 5 +++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index d51e300..c9c44d9 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -3364,6 +3364,41 @@ describe("initAction", () => { expect(fs.atomicWriteFile).toHaveBeenCalled(); }); + it("tags the printed login URL as cli-init", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const browserService = createMockBrowserService(); + const authService = createMockAuthService(); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + const createLoginDeps = mock(() => + Promise.resolve({ + authService, + authStorage: createMockAuthStorage(), + browserService, + mcpUrl: "https://mcp.githits.com", + }), + ); + + await initAction( + { browser: false, port: 8765 }, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps, + }, + ); + + const logCalls = getLogOutput(); + expect( + logCalls.some((msg) => msg.includes("utm_campaign=cli-init")), + ).toBe(true); + expect( + logCalls.some((msg) => msg.includes("utm_source=githits-cli")), + ).toBe(true); + }); + it("prompts to continue when login fails", async () => { const fs = createFsWithDetection(["/home/test/.cursor"]); const promptService = createMockPromptService({ diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index d765b06..a906a45 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -27,7 +27,7 @@ import type { LoginFlowResult, LoginOutput, } from "../login.js"; -import { loginFlow } from "../login.js"; +import { type LoginOptions, loginFlow } from "../login.js"; import { addOAuthCallbackOptions, type OAuthCallbackOptions, @@ -2005,7 +2005,8 @@ async function runInitAuthentication( } } - const loginOptions: OAuthCallbackOptions = { + const loginOptions: LoginOptions = { + entrypoint: "init", ...(options.browser !== undefined ? { browser: options.browser } : {}), ...(options.port !== undefined ? { port: options.port } : {}), }; From 60c0e2a78e7886f02340dcab8964bb2ccbb9d852 Mon Sep 17 00:00:00 2001 From: eze Date: Mon, 27 Jul 2026 12:39:05 -0300 Subject: [PATCH 5/6] docs: record CLI campaign attribution and its scope limits States plainly that the funnel measures auth-page arrival rather than command execution, that signup versus sign-in cannot come from the UTM because the URL is built before the user authenticates, and that cookieless_mode on_reject makes the result a directional lower bound. Without these the numbers read as a conversion rate, which they are not. Co-Authored-By: Claude Opus 5 (1M context) --- docs/implementation/auth.md | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index 84a00db..71a02eb 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -222,3 +222,55 @@ The MCP server starts without a synchronous auth gate. Tool calls resolve tokens | `src/services/filesystem-service.ts` | File system abstraction for testable storage | | `src/auth/pkce.ts` | PKCE cryptographic primitives | | `src/services/config.ts` | URL and API token configuration | + +## Campaign attribution + +The authorization URL is tagged with UTM parameters before it is opened or +printed (`src/commands/login.ts`, via `withUtm` in `src/shared/utm.ts`), so +PostHog can attribute web signups to the CLI. + +| Parameter | Value | +| --- | --- | +| `utm_source` | `githits-cli` | +| `utm_medium` | `cli` | +| `utm_campaign` | `cli-login` or `cli-init` | +| `utm_content` | CLI version | + +Only the five standard `utm_*` parameters are used, because PostHog +auto-captures exactly that set (`CAMPAIGN_PARAMS` in posthog-js). A custom +parameter name would not be captured. No analytics SDK is bundled and the CLI +sends no events of its own. + +The URL is tagged once, immediately after `buildAuthUrl`, and the tagged value +is used in all three places it is consumed: the browser open, the `--no-browser` +printout, and the "browser did not open" fallback. Tagging only the browser path +would drop attribution for SSH users and for anyone whose browser failed to +launch — the segments most likely to abandon, which would bias the funnel +upward. + +Tagging deliberately sits above `buildAuthUrl` rather than inside it. OAuth +parameters are correctness-critical while attribution is best-effort, and the +two should not share a failure mode. `withUtm` never throws and returns the URL +unchanged if it does not parse. + +### What this measures + +**Auth-page arrival → OAuth completion.** Not command execution → completion. + +A UTM parameter only exists once a browser requests the URL, so anyone who runs +the command and never reaches the auth page produces no signal and is absent +from the denominator. Numbers from this funnel are a lower bound on real +command-to-signup conversion, not a conversion rate. + +**Signup vs sign-in does not come from the UTM.** The URL is built before the +user authenticates, so at tagging time it is not yet known whether an account +will be created. The UTM carries origin only; the outcome comes from the +frontend's own signup event, and the two are joined at query time on the person. + +The web app runs `posthog-js` with `cookieless_mode: "on_reject"`. Users who +reject cookies have no `$anon_distinct_id` and cannot be linked to their signup; +users who never answer the banner have their events dropped entirely. Treat the +result as directional and comparative — `cli-init` vs `cli-login`, or movement +across releases — rather than absolute. + +Full analysis: `docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md`. From ffd0191c29ce9729083b2b72302f8e1b19ee3ede Mon Sep 17 00:00:00 2001 From: eze Date: Mon, 27 Jul 2026 13:04:18 -0300 Subject: [PATCH 6/6] docs: note custom_campaign_params as an alternative to standard slots The previous wording said a custom parameter name would not be captured, which is only true under the default configuration. posthog-js exposes custom_campaign_params to extend the captured list. Records why the design still uses the five standard slots: a custom name needs a coordinated frontend config change and captures nothing until that ships, so standard slots keep the CLI side independent of a web deploy. Co-Authored-By: Claude Opus 5 (1M context) --- docs/implementation/auth.md | 8 +++++--- .../specs/2026-07-27-cli-utm-attribution-design.md | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index 71a02eb..1345aa3 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -237,9 +237,11 @@ PostHog can attribute web signups to the CLI. | `utm_content` | CLI version | Only the five standard `utm_*` parameters are used, because PostHog -auto-captures exactly that set (`CAMPAIGN_PARAMS` in posthog-js). A custom -parameter name would not be captured. No analytics SDK is bundled and the CLI -sends no events of its own. +auto-captures exactly that set (`CAMPAIGN_PARAMS` in posthog-js) under the +default configuration. A custom parameter name would need the frontend to extend +the list via `custom_campaign_params`, and would capture nothing until that +config change ships — so the standard slots keep the CLI independent of a web +deploy. No analytics SDK is bundled and the CLI sends no events of its own. The URL is tagged once, immediately after `buildAuthUrl`, and the tagged value is used in all three places it is consumed: the browser open, the `--no-browser` diff --git a/docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md b/docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md index 7ee4690..93fb0b0 100644 --- a/docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md +++ b/docs/superpowers/specs/2026-07-27-cli-utm-attribution-design.md @@ -68,9 +68,14 @@ These land on events as `$utm_source`, `$utm_medium`, etc. PostHog also maintain initial/session-scoped copies (`packages/browser/src/session-props.ts`). **Consequence: no manual `set_once` implementation is needed.** The web audit -found nothing that automatic capture fails to cover for consenting users. Any -custom parameter outside this list would *not* be captured automatically — which -is why the design confines itself to these five slots. +found nothing that automatic capture fails to cover for consenting users. + +A parameter outside this list is not captured under the default configuration. +posthog-js does expose `custom_campaign_params` to extend the list, so a custom +name is possible — but it requires a coordinated frontend config change, and it +would silently capture nothing until that change ships. The design confines +itself to the five standard slots so the CLI side works against the frontend as +it is configured today, with no coupling to a web deploy. ### `identify()` links the anonymous visit to the signed-up user — CONFIRMED