← back to Cli Printing Press
feat(cli): add Chrome cookie auth for sniff-discovered APIs (#113)
b0a3815150d9e30f523d046f5eadfd0a9801b0be · 2026-04-03 10:36:50 -0700 · Trevin Chow
* feat(cli): add Chrome cookie auth for sniff-discovered APIs
When the sniff discovers cookie-authenticated endpoints, printed CLIs now
support `auth login --chrome` — reading cookies from the user's Chrome
session at login time. Includes auto-detection of Chrome profiles,
multi-tool support (pycookiecheat, cookies CLI, cookie-scoop-cli), and
sniff-time validation to confirm cookie replay works before promising
the feature.
Pipeline: sniff detects cookie auth → specgen populates CookieDomain →
generator selects auth_browser.go.tmpl → printed CLI gets --chrome flag,
doctor checks, and README docs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): prefer local build over go-install binary in setup contract
When running skills from inside the printing-press repo, the setup
contract now detects ./printing-press (kept current by lefthook) and
prepends the repo dir to PATH. This ensures skills use the local build
with all unreleased features instead of the older go-install version at
~/go/bin.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): add Domino's retro and sniff auth improvement plan
Retro from Domino's Pizza CLI generation identified 6 findings:
authenticated sniff flow missing, GraphQL BFF ops not inspected,
cookie auth not propagated, command aliases manual, local build
mismatch, and apostrophe name mangling.
Plan covers 3 work units: auth-aware sniff flow, GraphQL BFF
detection, and cleanSpecName apostrophe fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): auth sniff flow, GraphQL BFF detection, apostrophe naming
Three fixes from the Domino's retro:
1. cleanSpecName strips apostrophes before hyphenation so brand names
like "Domino's" produce "dominos" not "domino-s". Also handles
Unicode right single quotation mark (U+2019).
2. sniff-capture.md Step 2a.1.5: when AUTH_SESSION_AVAILABLE=true,
the sniff visits account/profile/history/rewards pages after the
primary flow, classifies endpoints as auth-required vs public, and
triggers cookie auth validation (Step 2d) to propagate Auth.Type
into the spec.
3. sniff-capture.md Step 2a.2.5: when >50% of XHR POSTs go to the
same URL (GraphQL BFF pattern), extracts operationName from request
bodies via agent-browser network requests or browser-use fetch
interceptor.
Also relaxes contracts_test to allow local-build PATH references in
setup contracts (needed for the local-build preference added in
bb33d2d).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): always use browser-use for capture, agent-browser for session only
The sniff-capture.md recommended agent-browser auto-connect for capture
when Chrome was running. But auto-connect mode can't access the DOM via
eval, snapshot, or click — it can only navigate and record HAR. This led
to a failed sniff attempt on Domino's where we couldn't interact with
pages or extract GraphQL operations.
Fix: agent-browser is now only used for session transfer (grabbing
cookies from running Chrome). The actual capture always uses browser-use
which has full DOM access. Flow: grab cookies → quit Chrome → browser-use
--profile "Default" for capture.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): sniff capture robustness — cardinal rules, session expiry, SPA nav
Three fixes from the Pagliacci retro to prevent recurring sniff failures:
1. Cardinal rules at top of sniff-capture.md: always use browser-use for
capture (not curl/agent-browser), never skip auth on session expiry,
use click navigation after installing interceptors.
2. Session expiry detection: after loading a Chrome profile, check if the
site shows login indicators. If session expired, offer headed login
instead of silently proceeding without auth.
3. SPA navigation rule: browser-use open resets the JS context, destroying
fetch/XHR interceptors. After installing interceptors, use click-based
SPA navigation to preserve them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): add auth header discovery and JS bundle extraction to sniff
Two new sniff capture steps from the Pagliacci retro WU-2:
Step 2a.1.5 item 5: Auth header discovery via XHR interception. Many
SPAs construct Authorization headers from cookies/localStorage rather
than sending cookies directly. The new step installs an XHR header
interceptor, triggers SPA navigation, and captures the actual auth
scheme (e.g., "PagliacciAuth {id}|{token}", "Bearer {jwt}"). Uses
the discovered header for auth validation instead of cookie replay.
Step 2a.2.7: JS bundle endpoint extraction. After interactive sniff,
extracts API paths from the SPA's compiled JS bundle as a supplementary
discovery technique. Finds endpoints no user flow visits (migration,
admin, rarely-used features). Marked as supplementary — the interactive
sniff remains primary for response shapes and auth patterns.
Also extends the discovery report template with auth scheme details
and bundle extraction results.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): composed cookie auth for sniff-discovered APIs
When the sniff discovers an API using a custom Authorization header
composed from browser cookie values (e.g., PagliacciAuth {customerId}|
{authToken}), the generator now emits a CLI with auth login --chrome
that reads the specific named cookies, composes the header, and saves
it to config.
Pipeline: sniff traces header values → cookie names → format string →
spec carries Auth.Type=composed + Auth.Format + Auth.Cookies →
generator extends auth_browser.go.tmpl with composed branch →
printed CLI reads named cookies and fills format template.
Changes:
- AuthConfig.Cookies []string field for named cookie list
- Generator routes composed → auth_browser.go.tmpl (shared template)
- auth_browser.go.tmpl: composed branch picks named cookies, fills
format string with {cookieName} placeholders, saves composed header
- config.go.tmpl: composed branch returns pre-composed AccessToken
- doctor.go.tmpl + readme.md.tmpl: composed treated same as cookie
for tool checks and Quick Start docs
- sniff-capture.md: auth header discovery now traces values to cookies,
constructs format string, writes composed auth config into spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): WAL-aware cookie probing and Windows path escaping in auth template
Two fixes from Codex review of the composed cookie auth feature:
1. countCookiesForDomain now copies Cookies-wal and Cookies-shm
alongside the main DB. When Chrome is running, recent cookie
writes are in the WAL — without it, profile discovery misses
active sessions and auth login --chrome reports no cookies.
2. extractViaPycookiecheat uses filepath.ToSlash before embedding
the cookie DB path into the Python command string. On Windows,
backslashes in paths like C:\Users\...\Profile 1\Cookies were
interpreted as Python escape sequences (\U, \t), breaking
pycookiecheat for non-default profiles.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): live browser fallback for session cookies in composed auth
When auth login --chrome finds domain cookies but not the required
named cookies (session cookies not persisted to disk), the CLI now
falls back to live browser extraction:
1. agent-browser --auto-connect: attaches to running Chrome, evals
document.cookie
2. browser-use --connect: same approach via browser-use
3. Raw CDP: connects to localhost:9222 debug port (tab discovery
only — WebSocket eval not yet implemented)
Also adds x-auth-type/x-auth-format/x-auth-cookies/x-auth-cookie-domain
OpenAPI extensions to the parser so composed auth can be expressed in
OpenAPI specs (used by the Pagliacci spec).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): reclassify path param modifiers as flags with defaults
Path params that are modifiers (pagination, enums, dates) are now
rendered as --flags with sensible defaults instead of required positional
args. This improves UX across all printed CLIs:
Before: order-list 1 10 (which arg is page vs size?)
After: order-list --page 2 (defaults: page=1, page-size=10)
Classification heuristic (first match wins):
1. Has enum → flag, defaults to first value
2. Has spec-declared default → flag with that default
3. Known pagination name (page, pageSize, limit, offset...) → flag
4. Date/time format or name → flag (no default)
5. Everything else → stays positional (entity identifiers)
New PathParam field on spec.Param distinguishes "path param rendered as
flag" from "query param" so the template substitutes the value into the
URL path (replacePathParam) instead of appending it as a query string.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): flagName converts camelCase to kebab-case
flagName("pageSize") now produces "page-size" instead of "pagesize".
Inserts hyphens at camelCase boundaries (lower→upper transitions) and
handles acronyms ("storeID" → "store-id"). This affects all printed
CLIs — any param named in camelCase gets a properly hyphenated flag.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): reclassified path params are optional when they have defaults
Path params reclassified as flags (pagination, enums, dates) were still
marked Required=true from the original path param processing. This made
them mandatory even though they have sensible defaults. Now
reclassification clears Required so users can just run `order-list`
instead of `order-list --page 1 --page-size 10`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): date path params stay required — no sensible default for URL
Path params reclassified as flags but without defaults (dates, years)
must stay Required=true because the URL path needs a value in that
segment. Only params with actual defaults (pagination, enums) become
optional. Without this, /TimeWindows/490/PICK/ gets a trailing empty
segment that 404s.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): smart auto-table with priority columns and card layout
The auto-table now prioritizes high-gravity fields (ID, dates, status,
price, summary) over metadata (booleans, building IDs) using tiered
case-insensitive substring matching. Booleans are demoted to last.
For complex responses (>8 fields or nested arrays), switches to a
card/sectional layout that shows one labeled block per item instead of
a cramped table. Arrays like Summary are flattened to comma-separated
strings.
Before: BUILDING BUILDINGNAME CLONEABLE COMMENTS DELTYPE
After: ID 40976858 — 2026-03-22T18:02:26
StatusCode: Completed
Summary: 1 Pizza, 1 Pagliaccio
Price: 5.98
...
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.mdA docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.mdA docs/plans/2026-04-03-001-fix-sniff-auth-graphql-naming-plan.mdA docs/plans/2026-04-03-002-feat-composed-cookie-auth-plan.mdA docs/retros/2026-04-03-dominos-retro.mdA docs/retros/2026-04-03-pagliacci-retro.mdM internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/auth_browser.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/config.go.tmplM internal/generator/templates/doctor.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/readme.md.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/contracts_test.goM internal/spec/spec.goM internal/spec/spec_test.goM internal/websniff/specgen.goM internal/websniff/specgen_test.goM skills/printing-press-catalog/SKILL.mdM skills/printing-press-publish/SKILL.mdM skills/printing-press-score/SKILL.mdM skills/printing-press/SKILL.mdM skills/printing-press/references/sniff-capture.md
Diff
commit b0a3815150d9e30f523d046f5eadfd0a9801b0be
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri Apr 3 10:36:50 2026 -0700
feat(cli): add Chrome cookie auth for sniff-discovered APIs (#113)
* feat(cli): add Chrome cookie auth for sniff-discovered APIs
When the sniff discovers cookie-authenticated endpoints, printed CLIs now
support `auth login --chrome` — reading cookies from the user's Chrome
session at login time. Includes auto-detection of Chrome profiles,
multi-tool support (pycookiecheat, cookies CLI, cookie-scoop-cli), and
sniff-time validation to confirm cookie replay works before promising
the feature.
Pipeline: sniff detects cookie auth → specgen populates CookieDomain →
generator selects auth_browser.go.tmpl → printed CLI gets --chrome flag,
doctor checks, and README docs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): prefer local build over go-install binary in setup contract
When running skills from inside the printing-press repo, the setup
contract now detects ./printing-press (kept current by lefthook) and
prepends the repo dir to PATH. This ensures skills use the local build
with all unreleased features instead of the older go-install version at
~/go/bin.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): add Domino's retro and sniff auth improvement plan
Retro from Domino's Pizza CLI generation identified 6 findings:
authenticated sniff flow missing, GraphQL BFF ops not inspected,
cookie auth not propagated, command aliases manual, local build
mismatch, and apostrophe name mangling.
Plan covers 3 work units: auth-aware sniff flow, GraphQL BFF
detection, and cleanSpecName apostrophe fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): auth sniff flow, GraphQL BFF detection, apostrophe naming
Three fixes from the Domino's retro:
1. cleanSpecName strips apostrophes before hyphenation so brand names
like "Domino's" produce "dominos" not "domino-s". Also handles
Unicode right single quotation mark (U+2019).
2. sniff-capture.md Step 2a.1.5: when AUTH_SESSION_AVAILABLE=true,
the sniff visits account/profile/history/rewards pages after the
primary flow, classifies endpoints as auth-required vs public, and
triggers cookie auth validation (Step 2d) to propagate Auth.Type
into the spec.
3. sniff-capture.md Step 2a.2.5: when >50% of XHR POSTs go to the
same URL (GraphQL BFF pattern), extracts operationName from request
bodies via agent-browser network requests or browser-use fetch
interceptor.
Also relaxes contracts_test to allow local-build PATH references in
setup contracts (needed for the local-build preference added in
bb33d2d).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): always use browser-use for capture, agent-browser for session only
The sniff-capture.md recommended agent-browser auto-connect for capture
when Chrome was running. But auto-connect mode can't access the DOM via
eval, snapshot, or click — it can only navigate and record HAR. This led
to a failed sniff attempt on Domino's where we couldn't interact with
pages or extract GraphQL operations.
Fix: agent-browser is now only used for session transfer (grabbing
cookies from running Chrome). The actual capture always uses browser-use
which has full DOM access. Flow: grab cookies → quit Chrome → browser-use
--profile "Default" for capture.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): sniff capture robustness — cardinal rules, session expiry, SPA nav
Three fixes from the Pagliacci retro to prevent recurring sniff failures:
1. Cardinal rules at top of sniff-capture.md: always use browser-use for
capture (not curl/agent-browser), never skip auth on session expiry,
use click navigation after installing interceptors.
2. Session expiry detection: after loading a Chrome profile, check if the
site shows login indicators. If session expired, offer headed login
instead of silently proceeding without auth.
3. SPA navigation rule: browser-use open resets the JS context, destroying
fetch/XHR interceptors. After installing interceptors, use click-based
SPA navigation to preserve them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): add auth header discovery and JS bundle extraction to sniff
Two new sniff capture steps from the Pagliacci retro WU-2:
Step 2a.1.5 item 5: Auth header discovery via XHR interception. Many
SPAs construct Authorization headers from cookies/localStorage rather
than sending cookies directly. The new step installs an XHR header
interceptor, triggers SPA navigation, and captures the actual auth
scheme (e.g., "PagliacciAuth {id}|{token}", "Bearer {jwt}"). Uses
the discovered header for auth validation instead of cookie replay.
Step 2a.2.7: JS bundle endpoint extraction. After interactive sniff,
extracts API paths from the SPA's compiled JS bundle as a supplementary
discovery technique. Finds endpoints no user flow visits (migration,
admin, rarely-used features). Marked as supplementary — the interactive
sniff remains primary for response shapes and auth patterns.
Also extends the discovery report template with auth scheme details
and bundle extraction results.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): composed cookie auth for sniff-discovered APIs
When the sniff discovers an API using a custom Authorization header
composed from browser cookie values (e.g., PagliacciAuth {customerId}|
{authToken}), the generator now emits a CLI with auth login --chrome
that reads the specific named cookies, composes the header, and saves
it to config.
Pipeline: sniff traces header values → cookie names → format string →
spec carries Auth.Type=composed + Auth.Format + Auth.Cookies →
generator extends auth_browser.go.tmpl with composed branch →
printed CLI reads named cookies and fills format template.
Changes:
- AuthConfig.Cookies []string field for named cookie list
- Generator routes composed → auth_browser.go.tmpl (shared template)
- auth_browser.go.tmpl: composed branch picks named cookies, fills
format string with {cookieName} placeholders, saves composed header
- config.go.tmpl: composed branch returns pre-composed AccessToken
- doctor.go.tmpl + readme.md.tmpl: composed treated same as cookie
for tool checks and Quick Start docs
- sniff-capture.md: auth header discovery now traces values to cookies,
constructs format string, writes composed auth config into spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): WAL-aware cookie probing and Windows path escaping in auth template
Two fixes from Codex review of the composed cookie auth feature:
1. countCookiesForDomain now copies Cookies-wal and Cookies-shm
alongside the main DB. When Chrome is running, recent cookie
writes are in the WAL — without it, profile discovery misses
active sessions and auth login --chrome reports no cookies.
2. extractViaPycookiecheat uses filepath.ToSlash before embedding
the cookie DB path into the Python command string. On Windows,
backslashes in paths like C:\Users\...\Profile 1\Cookies were
interpreted as Python escape sequences (\U, \t), breaking
pycookiecheat for non-default profiles.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): live browser fallback for session cookies in composed auth
When auth login --chrome finds domain cookies but not the required
named cookies (session cookies not persisted to disk), the CLI now
falls back to live browser extraction:
1. agent-browser --auto-connect: attaches to running Chrome, evals
document.cookie
2. browser-use --connect: same approach via browser-use
3. Raw CDP: connects to localhost:9222 debug port (tab discovery
only — WebSocket eval not yet implemented)
Also adds x-auth-type/x-auth-format/x-auth-cookies/x-auth-cookie-domain
OpenAPI extensions to the parser so composed auth can be expressed in
OpenAPI specs (used by the Pagliacci spec).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): reclassify path param modifiers as flags with defaults
Path params that are modifiers (pagination, enums, dates) are now
rendered as --flags with sensible defaults instead of required positional
args. This improves UX across all printed CLIs:
Before: order-list 1 10 (which arg is page vs size?)
After: order-list --page 2 (defaults: page=1, page-size=10)
Classification heuristic (first match wins):
1. Has enum → flag, defaults to first value
2. Has spec-declared default → flag with that default
3. Known pagination name (page, pageSize, limit, offset...) → flag
4. Date/time format or name → flag (no default)
5. Everything else → stays positional (entity identifiers)
New PathParam field on spec.Param distinguishes "path param rendered as
flag" from "query param" so the template substitutes the value into the
URL path (replacePathParam) instead of appending it as a query string.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): flagName converts camelCase to kebab-case
flagName("pageSize") now produces "page-size" instead of "pagesize".
Inserts hyphens at camelCase boundaries (lower→upper transitions) and
handles acronyms ("storeID" → "store-id"). This affects all printed
CLIs — any param named in camelCase gets a properly hyphenated flag.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): reclassified path params are optional when they have defaults
Path params reclassified as flags (pagination, enums, dates) were still
marked Required=true from the original path param processing. This made
them mandatory even though they have sensible defaults. Now
reclassification clears Required so users can just run `order-list`
instead of `order-list --page 1 --page-size 10`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): date path params stay required — no sensible default for URL
Path params reclassified as flags but without defaults (dates, years)
must stay Required=true because the URL path needs a value in that
segment. Only params with actual defaults (pagination, enums) become
optional. Without this, /TimeWindows/490/PICK/ gets a trailing empty
segment that 404s.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): smart auto-table with priority columns and card layout
The auto-table now prioritizes high-gravity fields (ID, dates, status,
price, summary) over metadata (booleans, building IDs) using tiered
case-insensitive substring matching. Booleans are demoted to last.
For complex responses (>8 fields or nested arrays), switches to a
card/sectional layout that shows one labeled block per item instead of
a cramped table. Arrays like Summary are flattened to comma-separated
strings.
Before: BUILDING BUILDINGNAME CLONEABLE COMMENTS DELTYPE
After: ID 40976858 — 2026-03-22T18:02:26
StatusCode: Completed
Summary: 1 Pizza, 1 Pagliaccio
Price: 5.98
...
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...2026-04-03-composed-cookie-auth-requirements.md | 143 ++++
...02-001-feat-browser-auth-cookie-runtime-plan.md | 326 +++++++++
...04-03-001-fix-sniff-auth-graphql-naming-plan.md | 214 ++++++
...026-04-03-002-feat-composed-cookie-auth-plan.md | 326 +++++++++
docs/retros/2026-04-03-dominos-retro.md | 204 ++++++
docs/retros/2026-04-03-pagliacci-retro.md | 211 ++++++
internal/generator/generator.go | 41 +-
internal/generator/generator_test.go | 112 +++
internal/generator/templates/auth_browser.go.tmpl | 796 +++++++++++++++++++++
.../generator/templates/command_endpoint.go.tmpl | 9 +-
internal/generator/templates/config.go.tmpl | 22 +
internal/generator/templates/doctor.go.tmpl | 31 +
internal/generator/templates/helpers.go.tmpl | 207 +++++-
internal/generator/templates/readme.md.tmpl | 18 +
internal/openapi/parser.go | 107 +++
internal/openapi/parser_test.go | 109 +++
internal/pipeline/contracts_test.go | 4 +-
internal/spec/spec.go | 5 +-
internal/spec/spec_test.go | 53 ++
internal/websniff/specgen.go | 18 +-
internal/websniff/specgen_test.go | 3 +-
skills/printing-press-catalog/SKILL.md | 15 +-
skills/printing-press-publish/SKILL.md | 15 +-
skills/printing-press-score/SKILL.md | 15 +-
skills/printing-press/SKILL.md | 23 +-
skills/printing-press/references/sniff-capture.md | 303 +++++++-
26 files changed, 3219 insertions(+), 111 deletions(-)
diff --git a/docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md b/docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md
new file mode 100644
index 00000000..6c1dc0a9
--- /dev/null
+++ b/docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md
@@ -0,0 +1,143 @@
+---
+date: 2026-04-03
+topic: composed-cookie-auth
+---
+
+# Composed Cookie Auth for Sniff-Discovered APIs
+
+## Problem Frame
+
+When the printing press sniffs a website to discover its API, it often finds that authentication uses a custom header composed from browser cookie values — not a standard Bearer token or API key. For example, Pagliacci Pizza uses `Authorization: PagliacciAuth {customerId}|{authToken}` where both values come from cookies set after login.
+
+Today, the sniff discovers this pattern (Step 2a.1.5 item 5 captures the auth header and traces values to cookies), but the pipeline can't act on it. The spec has no way to express "compose this header from these cookie values," and the generator has no template for it. The printed CLI falls back to generic "paste your token" auth, which means the user has to manually extract cookies from DevTools and construct the header string — exactly the friction the printing press exists to eliminate.
+
+The existing `auth_browser.go.tmpl` handles the simpler case where the entire cookie string IS the auth header (`Cookie: name=value; ...`). This feature handles the more common SPA pattern where specific cookies are read and composed into a custom Authorization header.
+
+## Auth Pattern Flow
+
+```
+┌─────────────────────────────────────────────────────┐
+│ SNIFF TIME │
+│ │
+│ XHR interceptor captures: │
+│ Authorization: PagliacciAuth 2432962|FD44DA6A... │
+│ │
+│ Cookie scan finds: │
+│ customerId=2432962 │
+│ authToken=FD44DA6A-F91C-42E0-AB7D-8A2D35DD655F │
+│ │
+│ Sniff writes to spec: │
+│ auth: │
+│ type: composed │
+│ format: "PagliacciAuth {customerId}|{authToken}"│
+│ cookie_domain: pagliacci.com │
+│ cookies: [customerId, authToken] │
+└──────────────────────┬──────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────┐
+│ GENERATE TIME │
+│ │
+│ Generator sees Auth.Type == "composed" │
+│ Selects auth_composed.go.tmpl │
+│ Emits CLI with: │
+│ - auth login --chrome │
+│ - auth status │
+│ - auth logout │
+│ - doctor checks for cookie tool │
+│ - README documents the auth flow │
+└──────────────────────┬──────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────┐
+│ RUNTIME (user runs CLI) │
+│ │
+│ $ pagliacci-pp-cli rewards │
+│ Error: not authenticated. │
+│ Log in at https://pagliacci.com/login │
+│ Then run: pagliacci-pp-cli auth login --chrome │
+│ │
+│ $ pagliacci-pp-cli auth login --chrome │
+│ Reading cookies from Chrome for pagliacci.com... │
+│ ✓ Found customerId │
+│ ✓ Found authToken │
+│ Session saved. │
+│ │
+│ $ pagliacci-pp-cli rewards │
+│ Points: 847 | Next reward: 153 points away │
+└─────────────────────────────────────────────────────┘
+```
+
+## Requirements
+
+**Spec Format**
+
+- R1. `AuthConfig` supports `Type: "composed"` — a new auth type for headers composed from named cookie values
+- R2. `AuthConfig.Format` carries the header template string with `{cookieName}` placeholders (e.g., `"PagliacciAuth {customerId}|{authToken}"`)
+- R3. `AuthConfig.Cookies` (new field) lists the cookie names to extract, in the order they appear in the format string
+- R4. `AuthConfig.CookieDomain` carries the domain to read cookies from (already exists)
+
+**Generator**
+
+- R5. Generator selects `auth_composed.go.tmpl` when `Auth.Type == "composed"`
+- R6. The template emits `auth login --chrome` that reads the specific named cookies (not the entire cookie jar), composes them into the format string, and saves the resulting header value to config
+- R7. The template emits `auth status` showing whether composed auth is configured, the domain, and the config path
+- R8. The template emits `auth logout` clearing the saved header
+- R9. When auth is not configured and a command fails with 401, the CLI prints the login URL and the `auth login --chrome` command — no interactive prompting, no auto-retry
+- R10. The `doctor` template checks for cookie extraction tool availability when `Auth.Type == "composed"`
+- R11. The README template documents `auth login --chrome` in Quick Start when `Auth.Type == "composed"`
+
+**Sniff Pipeline**
+
+- R12. The sniff's auth header discovery (Step 2a.1.5 item 5) traces captured Authorization header values back to cookie names by matching literal values against `document.cookie` entries
+- R13. The sniff infers the format string by replacing literal cookie values with `{cookieName}` placeholders in the captured header
+- R14. The sniff writes `Auth.Type: composed`, `Auth.Format`, `Auth.Cookies`, and `Auth.CookieDomain` into the spec when a composed pattern is detected
+- R15. The sniff does NOT attempt to replay credentials or hit login endpoints — the browser is the auth source of truth
+
+**Runtime Behavior**
+
+- R16. `auth login --chrome` uses the same cookie extraction tools as `auth_browser.go.tmpl` (pycookiecheat, cookies CLI, cookie-scoop-cli) — the extraction mechanism is shared, only the post-extraction step differs
+- R17. After extracting cookies, the CLI composes the header using the baked-in format string and saves it to config as the auth header value
+- R18. The client attaches the composed header to requests using the header name from `Auth.Header` (typically `Authorization`)
+- R19. If the required cookies are not found in Chrome (user not logged in), the CLI prints the login URL derived from `Auth.CookieDomain` and exits with a non-zero status
+
+## Success Criteria
+
+- A printed CLI for an API with composed cookie auth (like Pagliacci) has `auth login --chrome` that works end-to-end: reads cookies, composes header, saves to config, subsequent commands authenticate
+- The user never sees the auth header format — they run one command and it just works
+- The same cookie extraction tools work for both `auth_browser.go.tmpl` (full cookie string) and `auth_composed.go.tmpl` (named cookies → format string)
+- Agent-friendly: no interactive prompts, clear error messages with exact commands, `--no-input` respected
+
+## Scope Boundaries
+
+- No credential-based login (no username/password flow in the CLI) — Chrome is the auth source
+- No auto-opening the browser — print the URL, let the user navigate
+- No auto-retry on 401 — fail with guidance
+- macOS Chrome only for v1 (same constraint as existing `auth_browser.go.tmpl`)
+- Does not change the existing `cookie` auth type — `composed` is a new type alongside it
+- Does not require the sniff to discover the login endpoint — only the auth header pattern and cookie names
+
+## Key Decisions
+
+- **Chrome import only, no credential flow:** The user authenticates through the site's intended login page in their browser. The CLI reads the result. This avoids handling credentials, hitting undocumented login endpoints, and dealing with CSRF/captcha.
+- **Fail with guidance, not auto-prompt:** When auth is missing, print the URL and the command. No interactive prompting on 401 — keeps the CLI agent-safe and predictable.
+- **New auth type, not extension of existing cookie type:** `composed` is distinct from `cookie` because the post-extraction step is different (compose format string vs. dump cookie string). Keeping them separate avoids breaking the existing cookie auth path.
+- **Format string with placeholders:** `"PagliacciAuth {customerId}|{authToken}"` is simple, readable, and covers the patterns we've seen. No need for a more complex templating language.
+
+## Dependencies / Assumptions
+
+- Depends on the sniff auth header discovery (Step 2a.1.5 item 5) already implemented in this branch
+- Assumes cookie extraction tools (pycookiecheat etc.) can read individual named cookies — verified: pycookiecheat returns a dict keyed by cookie name
+- The `AuthConfig.Format` field already exists in the spec struct but is unused for this purpose
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R3][Technical] What is the best YAML representation for the `Cookies` list — a new field on `AuthConfig` or reuse/extend an existing field?
+- [Affects R6][Technical] How much of `auth_browser.go.tmpl`'s cookie extraction code can be shared with `auth_composed.go.tmpl` vs. duplicated?
+- [Affects R12][Needs research] Can the sniff reliably match header values to cookie names when values contain special characters or are URL-encoded?
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md b/docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md
new file mode 100644
index 00000000..1da914dd
--- /dev/null
+++ b/docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md
@@ -0,0 +1,326 @@
+---
+title: "feat: Browser cookie auth — sniff-to-CLI runtime pipeline"
+type: feat
+status: draft
+date: 2026-04-02
+---
+
+# feat: Browser cookie auth — sniff-to-CLI runtime pipeline
+
+## Overview
+
+When the sniff discovers a cookie-authenticated API (no developer API keys, just browser sessions), the printed CLI should support `auth login --chrome` — reading Chrome cookies at login time so the CLI works whenever the user is logged in via their browser. This threads through the entire pipeline: sniff detection, spec generation, template rendering, and the final CLI's auth/doctor/README.
+
+## Problem Frame
+
+Today, when the sniff detects `cookie` auth, it writes `Format: "informational only; no template support"` into the spec. The generated CLI has no way to use cookie auth. The user gets a CLI that can't authenticate to the API it was built for — unless they manually extract cookies from DevTools and paste them into env vars. That's the exact friction this feature eliminates.
+
+The insight: if the user is logged into the site in Chrome, the CLI should just work. Cookies are read once during an explicit `auth login --chrome` action, saved to the CLI's config, and used for subsequent requests. The cookies stay in Chrome; the CLI reads them at login time, not silently on every request.
+
+## Requirements Trace
+
+- R1. Sniff detects cookie auth and records `CookieDomain` (from `AuthCapture.BoundDomain`)
+- R2. Sniff validates that Chrome cookies exist for the domain and that they produce authenticated responses — before promising the feature in the generated CLI
+- R3. If validation fails, the sniff tells the user: "Authenticated endpoints discovered but browser auth won't work for this API (CSRF/cookie policy). These endpoints will be included but marked auth-required."
+- R4. If validation passes, `Auth.Type = "cookie"` flows into the spec with `CookieDomain` populated
+- R5. Generator selects `auth_browser.go.tmpl` when `Auth.Type == "cookie"`
+- R6. Generated CLI has `auth login --chrome` that reads Chrome cookies for the domain and saves to config
+- R7. Generated CLI has `auth status` that shows browser session state and expiry
+- R8. Generated CLI's `doctor` checks for cookie tool availability and session freshness
+- R9. Generated CLI's README documents `auth login --chrome` in the Quick Start
+- R10. Cookie extraction is done by shelling out to an external tool — no custom crypto in printed CLIs
+- R11. `doctor` recommends installing a cookie tool if none found, with install instructions
+- R12. The client uses saved cookies from config — no special cookie injection path at request time
+
+## Scope Boundaries
+
+- macOS Chrome only for v1. Firefox/Safari/Edge/Linux/Windows are future work.
+- No custom cookie decryption code. Shell out to an existing tool.
+- No silent/ambient cookie reading — only during explicit `auth login --chrome`.
+- No CSRF auto-detection in v1. If CSRF is required, validation (R2) will fail and the feature won't be offered.
+- The cookie tool is not bundled — it's a recommended install, like browser-use/agent-browser for sniff.
+
+## Context & Research
+
+### Cookie extraction tools (shell-out candidates)
+
+The printed CLI shells out to one of these at `auth login --chrome` time:
+
+| Tool | Install | Output | Notes |
+|------|---------|--------|-------|
+| `pycookiecheat` | `pip install pycookiecheat` | JSON (domain-filtered) | 809 stars, maintained since 2015, handles Chrome v24 format. Requires Python. Most battle-tested. |
+| `barnardb/cookies` | `brew install barnardb/cookies/cookies` | Cookie header string | 74 stars, Go/Homebrew, built on kooky. Easiest macOS install. |
+| `cookie-scoop-cli` | `cargo install cookie-scoop-cli` | JSON or header | Rust, static binary. Inspired by steipete/sweet-cookie. Newest. |
+
+The printed CLI checks for any of these (same pattern as browser-use vs agent-browser detection in sniff). `doctor` reports which is available and how to install if none found.
+
+### Underlying algorithm (stable, well-understood)
+
+All three tools implement the same ~100 lines of logic:
+1. Copy Chrome's `~/Library/Application Support/Google/Chrome/Default/Cookies` SQLite file
+2. Query for cookies matching the target domain
+3. Get Keychain password via `/usr/bin/security find-generic-password -s "Chrome Safe Storage" -w`
+4. PBKDF2 + AES-128-CBC decrypt
+
+This scheme has been stable for ~8 years on macOS. The Keychain prompt is the only user-visible side effect (one-time if "Always Allow" is clicked).
+
+### Relevant code and patterns
+
+| Purpose | File |
+|---------|------|
+| Current cookie auth detection (returns "informational only") | `internal/websniff/specgen.go:318-332` |
+| AuthConfig struct (needs `CookieDomain` field) | `internal/spec/spec.go:27-38` |
+| AuthCapture with BoundDomain | `internal/websniff/types.go:52-58` |
+| Auth template selection logic | `internal/generator/generator.go:341-350` |
+| Simple auth template (model for browser auth template) | `internal/generator/templates/auth_simple.go.tmpl` |
+| Doctor template (needs browser session check) | `internal/generator/templates/doctor.go.tmpl` |
+| README template (needs cookie auth Quick Start section) | `internal/generator/templates/README.md.tmpl` |
+| Client template (no changes — uses config like all auth types) | `internal/generator/templates/client.go.tmpl` |
+| Sniff skill Phase 1.6 (pre-sniff auth intelligence) | `skills/printing-press/SKILL.md:510-551` |
+| Sniff capture reference (session transfer) | `skills/printing-press/references/sniff-capture.md` |
+
+## Key Technical Decisions
+
+### D1: Cookie tool detection order
+
+Check in this order: `pycookiecheat` (most reliable) > `cookies` (barnardb, easiest install) > `cookie-scoop-cli` (newest).
+
+### D2: Cookies saved to config, not re-read per request
+
+`auth login --chrome` reads cookies once and saves them to the CLI's config file (same `SaveTokens` mechanism used by OAuth2 and API key auth). All subsequent requests use config. This means:
+- No repeated Chrome reads or Keychain prompts
+- Standard config auth path in client.go — no special cookie injection
+- User runs `auth login --chrome` again when session expires
+
+### D3: Sniff-time validation before promising the feature
+
+Before the sniff generates a spec with `Auth.Type = "cookie"`, it must validate that the cookies actually produce authenticated responses. The sniff already has the cookies (it used them to capture traffic). Validation:
+1. Pick one endpoint that returned 200 during sniff
+2. Replay the request with cookies → expect 200
+3. Replay without cookies → expect 401/403 or different response
+4. If both succeed identically, cookies aren't actually required — mark as `Auth.Type = "none"`
+5. If cookied request fails, something is wrong (CSRF, cookie policy) — warn user, include endpoints but don't offer browser auth in the CLI
+
+### D4: Config storage format for cookies
+
+Cookies are stored as a single `Cookie` header value in the config (e.g., `token_v2=abc123; notion_browser_id=xyz`). This maps directly to `req.Header.Set("Cookie", value)` in the client. No changes to `config.go.tmpl` beyond treating it like any other auth header value.
+
+## Implementation Plan
+
+### Task 1: Add `CookieDomain` to `AuthConfig`
+
+**File:** `internal/spec/spec.go`
+
+Add `CookieDomain string` field to `AuthConfig`:
+```go
+CookieDomain string `yaml:"cookie_domain,omitempty" json:"cookie_domain,omitempty"`
+```
+
+**Test:** Update any existing AuthConfig tests to include the new field.
+
+### Task 2: Wire cookie auth detection in specgen
+
+**File:** `internal/websniff/specgen.go`
+
+Change `detectCapturedAuth` (lines 318-332) to populate real auth config instead of "informational only":
+
+```go
+case "cookie":
+ return spec.AuthConfig{
+ Type: "cookie",
+ Header: "Cookie",
+ In: "cookie",
+ CookieDomain: capture.BoundDomain,
+ EnvVars: envVarsOrNil(envPrefix, "COOKIES"),
+ }
+```
+
+Both the `len(capture.Headers) > 0` and `len(capture.Cookies) > 0` branches.
+
+**Test:** Update `specgen_test.go` — cookie auth capture should produce a usable AuthConfig, not "informational only".
+
+### Task 3: Create `auth_browser.go.tmpl`
+
+**File:** `internal/generator/templates/auth_browser.go.tmpl`
+
+New template for cookie-auth CLIs. Provides three subcommands:
+- `auth login --chrome` — detects cookie tool, shells out, saves cookies to config
+- `auth status` — shows session state, domain, cookie count, approximate expiry
+- `auth logout` — clears saved cookies from config
+
+Cookie tool detection logic (same pattern as sniff tool detection):
+```go
+func detectCookieTool() (string, error) {
+ for _, tool := range []struct{ name, check string }{
+ {"pycookiecheat", "python3 -m pycookiecheat --help"},
+ {"cookies", "cookies --help"},
+ {"cookie-scoop-cli", "cookie-scoop --help"},
+ } {
+ if exec.Command("sh", "-c", tool.check).Run() == nil {
+ return tool.name, nil
+ }
+ }
+ return "", fmt.Errorf("no cookie tool found")
+}
+```
+
+Extraction dispatch (each tool has different invocation/output format):
+- `pycookiecheat`: `python3 -c "from pycookiecheat import chrome_cookies; ..."` → JSON
+- `cookies`: `cookies https://<domain>` → Cookie header string
+- `cookie-scoop-cli`: `cookie-scoop --domain <domain> --header` → Cookie header string
+
+Save the resulting cookie header string to config via `cfg.SaveTokens()`.
+
+### Task 4: Generator template selection for cookie auth
+
+**File:** `internal/generator/generator.go`
+
+Update auth template selection (line 341-350) to add a third branch:
+
+```go
+authTmpl := "auth_simple.go.tmpl"
+if g.Spec.Auth.AuthorizationURL != "" {
+ authTmpl = "auth.go.tmpl"
+} else if g.Spec.Auth.Type == "cookie" {
+ authTmpl = "auth_browser.go.tmpl"
+}
+```
+
+### Task 5: Update `doctor.go.tmpl` for browser auth
+
+**File:** `internal/generator/templates/doctor.go.tmpl`
+
+Add cookie-auth-specific checks inside the `{{- else}}` block (after line 39):
+
+```go
+{{- if eq .Auth.Type "cookie"}}
+// Check cookie tool availability
+cookieTool, toolErr := detectCookieTool()
+if toolErr != nil {
+ report["cookie_tool"] = "not found — install: pip install pycookiecheat"
+} else {
+ report["cookie_tool"] = cookieTool
+}
+// Check if browser session is fresh
+if header != "" {
+ report["auth"] = "configured (browser session)"
+} else {
+ report["auth"] = "not configured — run: auth login --chrome"
+}
+{{- else}}
+// ... existing non-cookie auth checks
+{{- end}}
+```
+
+### Task 6: Update `README.md.tmpl` for cookie auth
+
+**File:** `internal/generator/templates/README.md.tmpl`
+
+Add a `{{- else if eq .Auth.Type "cookie"}}` branch after line 68:
+
+```markdown
+### 2. Authenticate
+
+This CLI uses your browser session for authentication. Log in to {{.Auth.CookieDomain}} in Chrome, then:
+
+ {{.Name}}-pp-cli auth login --chrome
+
+Requires a cookie extraction tool. Install one:
+
+ pip install pycookiecheat # Python (recommended)
+ brew install barnardb/cookies/cookies # Homebrew
+
+When your session expires, run `auth login --chrome` again.
+```
+
+### Task 7: Sniff-time cookie validation
+
+**File:** `skills/printing-press/references/sniff-capture.md`
+
+Add a new step after traffic capture and before spec generation: **Step 2d: Cookie Auth Validation**.
+
+When the sniff detects cookie auth in captured traffic:
+
+1. Select one endpoint that returned 200 during capture
+2. Replay with captured cookies → should get 200
+3. Replay without cookies → should get 401/403 or clearly different response
+4. **Pass**: Both conditions met → cookie auth is viable, proceed with `Auth.Type = "cookie"` and `CookieDomain` in spec
+5. **Fail**: Cookied request fails (CSRF/SameSite issues) → warn user:
+ > "Authenticated endpoints were discovered but browser cookie auth won't work for this API (likely requires CSRF tokens or has strict cookie policies). These endpoints will be included in the spec but the generated CLI won't offer `auth login --chrome`. You'll need to manually provide auth tokens."
+
+ Set `Auth.Type = "none"` with a note in the sniff report.
+
+### Task 8: Update Phase 1.6 skill text
+
+**File:** `skills/printing-press/SKILL.md`
+
+In the browser session auth section (line 541-548), add context about the `--chrome` login that will be available:
+
+> "If you're logged in, the generated CLI will include `auth login --chrome` — you'll be able to authenticate the CLI just by being logged into the site in Chrome. No API key needed."
+
+This sets the user's expectation during the sniff decision.
+
+## Dependency Graph
+
+```
+Task 1 (AuthConfig field)
+ └→ Task 2 (specgen wiring)
+ └→ Task 7 (sniff validation — skill change, no Go code dependency)
+ └→ Task 3 (auth_browser template)
+ └→ Task 4 (generator selection)
+ └→ Task 5 (doctor template)
+ └→ Task 6 (README template)
+Task 8 (skill text — independent)
+```
+
+Tasks 3, 5, 6, 8 can be done in parallel after Task 1.
+
+## UX Flow (end to end)
+
+### During sniff (printing press run)
+
+```
+Phase 1.6: "This API has order history and saved addresses that require a logged-in session.
+ Are you logged in to dominos.com in Chrome?"
+ → User: "Yes"
+
+Phase 1.7: Authenticated sniff runs, captures cookie-auth traffic
+
+Step 2d: Cookie validation passes — browser auth will work
+
+Phase 2: generate receives spec with Auth.Type=cookie, CookieDomain=.dominos.com
+```
+
+### In the generated CLI
+
+```bash
+$ dominos-pp-cli doctor
+✓ config ok
+✓ cookie_tool pycookiecheat
+⚠ auth not configured — run: dominos-pp-cli auth login --chrome
+✓ connectivity dominos.com reachable
+
+$ dominos-pp-cli auth login --chrome
+🔑 Reading Chrome cookies for .dominos.com...
+ [macOS Keychain prompt — user clicks Allow]
+✓ Found 8 cookies for .dominos.com
+✓ Session saved to ~/.config/dominos-pp-cli/config.toml
+
+$ dominos-pp-cli orders list
+# works
+
+# 3 hours later, session expired:
+$ dominos-pp-cli orders list
+✗ 401 Unauthorized
+
+$ dominos-pp-cli auth login --chrome
+✓ Session refreshed
+```
+
+## What's NOT in scope
+
+- Custom cookie decryption code in printed CLIs
+- Cross-platform (Linux/Windows) or multi-browser (Firefox/Safari)
+- CSRF token auto-detection or auto-fetching
+- Silent/ambient cookie reading (every request hitting Chrome)
+- Bundling a cookie tool inside the printed CLI binary
diff --git a/docs/plans/2026-04-03-001-fix-sniff-auth-graphql-naming-plan.md b/docs/plans/2026-04-03-001-fix-sniff-auth-graphql-naming-plan.md
new file mode 100644
index 00000000..35df0822
--- /dev/null
+++ b/docs/plans/2026-04-03-001-fix-sniff-auth-graphql-naming-plan.md
@@ -0,0 +1,214 @@
+---
+title: "fix: Authenticated sniff flow, GraphQL BFF discovery, and apostrophe naming"
+type: fix
+status: active
+date: 2026-04-03
+origin: docs/retros/2026-04-03-dominos-retro.md
+---
+
+# fix: Authenticated sniff flow, GraphQL BFF discovery, and apostrophe naming
+
+## Overview
+
+Three gaps discovered during the Domino's Pizza generation run. The sniff procedure doesn't explore authenticated endpoints when the user confirms login, doesn't inspect GraphQL POST bodies when it discovers a BFF pattern, and `cleanSpecName` mangles brand names with apostrophes.
+
+These are ordered by dependency: WU-3 (naming) is independent, WU-1 (auth sniff) is independent, WU-2 (GraphQL) is independent but benefits from WU-1 since GraphQL BFFs often have both public and authenticated operations.
+
+## Problem Frame
+
+The chrome-auth-sniff branch (`9c9bfa1`) built the downstream infrastructure: `Auth.Type=cookie` → `auth_browser.go.tmpl` → `auth login --chrome` in printed CLIs. But the upstream discovery path — the sniff procedure that identifies cookie-authenticated endpoints and feeds them into the spec — has three gaps that prevent the downstream infrastructure from activating. See `docs/retros/2026-04-03-dominos-retro.md` findings #1-3 and #6.
+
+## Requirements Trace
+
+- R1. When `AUTH_SESSION_AVAILABLE=true`, the sniff user-flow plan MUST include authenticated page visits (account, order history, rewards, profile) after the primary flow
+- R2. Endpoints discovered on authenticated pages must be classified as auth-required in the discovery report
+- R3. When auth-only endpoints are found, Step 2d (cookie auth validation) must run and propagate `Auth.Type=cookie` + `CookieDomain` into the spec
+- R4. When the sniff detects multiple POST requests to the same URL path, it must inspect request bodies for GraphQL `operationName` fields
+- R5. GraphQL operations must be recorded in the discovery report with operation name, type (query/mutation), and domain grouping
+- R6. `cleanSpecName("Domino's Pizza API")` must produce `"dominos-pizza"`, not `"domino-s-pizza"`
+- R7. All existing `cleanSpecName` behavior for names without apostrophes must be unchanged
+
+## Scope Boundaries
+
+- Does NOT change generator templates (`auth_browser.go.tmpl` already exists per `docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md`)
+- Does NOT change `specgen.go` (already handles cookie auth from HAR captures)
+- Does NOT add GraphQL schema introspection — only discovers operations from captured traffic
+- Does NOT implement command alias generation (retro finding #4 — deferred, needs larger design)
+- WU-1 changes skill instructions only; WU-2 changes skill instructions only; WU-3 changes Go code only
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/openapi/parser.go:1898-1967` — `cleanSpecName()` function. Character filtering at line 1909 keeps only `unicode.IsLetter()` and `unicode.IsDigit()`, treating apostrophes as non-alphanumeric → replaced with space → `"Domino's"` → `"domino s"` → `"domino-s"`
+- `skills/printing-press/references/sniff-capture.md` — Step numbering: 1→1d (detection/auth), 2a→2d (capture/validation), 3→5 (analysis/reporting). New auth flow goes in Step 2a.1; GraphQL detection goes after Step 2a.2
+- `skills/printing-press/SKILL.md` Phase 1.6 (line ~517) sets `AUTH_SESSION_AVAILABLE=true`; Phase 1.7 (line ~563) passes it to sniff-capture.md
+- `internal/websniff/specgen.go:318-334` — Already handles `captureType == "cookie"` and sets `CookieDomain` from `capture.BoundDomain`. The downstream pipeline works; the upstream doesn't feed it.
+
+### Institutional Learnings
+
+- Retro finding #5 (local build preference) was already fixed in commit `bb33d2d`
+- The browser auth plan (`2026-04-02-001`) covers R1-R12 of the downstream pipeline. This plan covers the upstream discovery that feeds it.
+
+### Related Plans
+
+- `docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md` — Complementary. That plan implements the generator templates and CLI runtime. This plan implements the sniff discovery that feeds the generator.
+
+## Key Technical Decisions
+
+- **Auth flow in sniff-capture.md, not SKILL.md:** The authenticated page visits belong in the capture procedure (sniff-capture.md Step 2a.1), not in the main skill's Phase 1.6/1.7. Phase 1.6 already sets the flag correctly; the gap is in the capture reference file that doesn't use it.
+- **GraphQL detection by URL convergence, not content-type:** The signal is multiple POST requests to the same path (all going to `/api/web-bff/graphql`). Content-type alone isn't sufficient since both REST and GraphQL use `application/json`.
+- **Apostrophe stripping before character filter, not after:** Strip apostrophes early (before the unicode filter loop) so `"Domino's"` → `"Dominos"` → normal flow. This is simpler and more correct than post-processing.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Q: Should GraphQL operations become REST-like spec paths?** Yes — the spec still uses OpenAPI format, so each GraphQL operation becomes a path like `/graphql#GetStoreMenu`. The generator already handles spec paths; no new generator changes needed.
+- **Q: What authenticated pages to visit?** Common patterns: `/account`, `/profile`, `/orders`, `/order-history`, `/rewards`, `/settings`, `/addresses`, `/payment-methods`. The skill should try common patterns and also derive page names from the research brief's top workflows.
+
+### Deferred to Implementation
+
+- **Q: How to handle GraphQL mutations discovered during auth page visits?** The sniff should record them but the spec builder may need to decide whether mutations become CLI commands. Defer to implementation.
+- **Q: Rate limiting during auth page visits?** The existing sniff pacing rules apply. No new logic needed.
+
+## Implementation Units
+
+- [ ] **Unit 1: Strip apostrophes in cleanSpecName (WU-3)**
+
+**Goal:** Brand names with apostrophes produce clean CLI names.
+
+**Requirements:** R6, R7
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/openapi/parser.go` (the `cleanSpecName` function, lines 1898-1967)
+- Test: `internal/openapi/parser_test.go`
+
+**Approach:**
+Add `strings.ReplaceAll(title, "'", "")` immediately after the lowercase/trim step (line 1899) and before the unicode character filter loop (line 1906). This strips possessive apostrophes so `"domino's"` → `"dominos"` before the filter runs.
+
+Also strip the Unicode right single quotation mark (`\u2019`, `'`) which appears in some formatted API titles.
+
+**Patterns to follow:**
+- Line 1903 already does `strings.ReplaceAll(title, "open api", " ")` — same pattern of pre-processing before the main filter loop
+
+**Test scenarios:**
+- Happy path: `cleanSpecName("Domino's Pizza API")` → `"dominos-pizza"`
+- Happy path: `cleanSpecName("McDonald's API")` → `"mcdonalds"`
+- Happy path: `cleanSpecName("Lowe's Home Improvement")` → `"lowes-home-improvement"`
+- Edge case: Unicode right quote `cleanSpecName("Domino\u2019s Pizza")` → `"dominos-pizza"`
+- Edge case: Multiple apostrophes `cleanSpecName("Rock'n'Roll API")` → `"rocknroll"`
+- Regression: `cleanSpecName("Stripe API")` → `"stripe"` (unchanged)
+- Regression: `cleanSpecName("Steam Web API")` → `"steam-web"` (unchanged)
+- Regression: `cleanSpecName("cal.com API")` → existing behavior unchanged
+
+**Verification:**
+- `go test ./internal/openapi/ -run TestCleanSpecName` passes with new cases
+- Existing tests still pass
+
+- [ ] **Unit 2: Add authenticated flow to sniff user-flow plan (WU-1)**
+
+**Goal:** When `AUTH_SESSION_AVAILABLE=true`, the sniff automatically visits authenticated pages after the primary flow, discovers auth-only endpoints, and classifies them.
+
+**Requirements:** R1, R2, R3
+
+**Dependencies:** None (but conceptually feeds into the browser-auth-cookie-runtime plan's pipeline)
+
+**Files:**
+- Modify: `skills/printing-press/references/sniff-capture.md` (Step 2a.1 — user flow plan section)
+
+**Approach:**
+Add a new sub-section to Step 2a.1 after the existing flow plan guidance. The section activates only when `AUTH_SESSION_AVAILABLE=true`. It instructs Claude to:
+
+1. After completing the primary flow, record which endpoints were discovered (the "public set")
+2. Navigate to common account/profile URLs derived from the site (e.g., `/account`, `/my-account`, `/profile`, `/orders`, `/order-history`, `/rewards`, `/settings`)
+3. Also derive auth page URLs from the research brief's top workflows — if the brief mentions "order history" or "rewards", visit those specific pages
+4. Capture endpoints from auth pages (the "auth set")
+5. Classify endpoints as auth-required if they appear ONLY in the auth set (not in the public set)
+6. If auth-only endpoints are found, ensure Step 2d (cookie auth validation) runs
+
+Also add guidance to the discovery report template (Step 5) to include an "Auth-Only Endpoints" section when auth-only endpoints were discovered.
+
+**Patterns to follow:**
+- Step 2a.1 already has domain-specific examples (Domino's, Linear, ESPN) — add a note about secondary auth flows
+- Step 2c (thin-results safety check) already compares discovered vs expected endpoints — similar comparison pattern
+
+**Test scenarios:**
+- Happy path: Sniff with `AUTH_SESSION_AVAILABLE=true` → auth pages appear in the user flow plan, auth-only endpoints listed in discovery report
+- Happy path: Auth-only endpoints found → Step 2d cookie validation triggered → spec gets `Auth.Type=cookie`
+- Edge case: Auth pages return 302 redirect to login → report as "auth page requires active session, session may have expired"
+- Edge case: No auth-only endpoints found (all endpoints accessible publicly) → report "no auth-only endpoints discovered" and skip cookie auth classification
+- Regression: Sniff without `AUTH_SESSION_AVAILABLE` (anonymous) → no auth page visits, existing behavior unchanged
+
+**Verification:**
+- Review sniff-capture.md for the new section
+- The section is gated on `AUTH_SESSION_AVAILABLE=true`
+- The section includes specific page URL patterns and research-brief-derived URLs
+
+- [ ] **Unit 3: Add GraphQL BFF detection to sniff capture (WU-2)**
+
+**Goal:** When the sniff detects multiple POST requests to the same URL path, it extracts GraphQL operation names from request bodies and records them in the discovery report.
+
+**Requirements:** R4, R5
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `skills/printing-press/references/sniff-capture.md` (new Step 2a.2.5 after URL collection)
+
+**Approach:**
+Add a new step between Step 2a.2 (Collect API URLs) and Step 2a.3 (Deduplicate and normalize). The step:
+
+1. After collecting URLs, check if >50% of captured XHR/fetch URLs resolve to the same POST endpoint
+2. If yes, classify as GraphQL BFF
+3. For each POST request to the BFF endpoint:
+ - For agent-browser: use `network requests --type xhr --json` to list requests, then `network request <id> --json` to get the full request including body
+ - For browser-use: inject a fetch interceptor before browsing that logs POST bodies to a known variable, then read them via `eval`
+4. Parse `operationName` and `query` fields from each request body
+5. Record operations in a structured format: `{operationName, type (query/mutation), variables}`
+6. Group operations by domain prefix (e.g., `GetStore*`, `GetMenu*`, `AddToOrder*`)
+7. Use operation names instead of URL paths for the discovery report's endpoint table
+
+Also update Step 5 (discovery report template) to include a "GraphQL Operations" section when BFF pattern is detected.
+
+**Patterns to follow:**
+- The existing "Proxy Pattern Detection" section (lines 20-38 of sniff-capture.md) already detects same-URL patterns — the GraphQL detection is a specialization of this
+- Step 2a.2 already uses Performance API for URL collection — GraphQL detection runs after URL collection
+
+**Test scenarios:**
+- Happy path: Sniff a site with GraphQL BFF → operations extracted, discovery report lists operation names instead of repeated `/graphql` path
+- Happy path: Mixed REST + GraphQL → REST endpoints listed by path, GraphQL operations listed by name
+- Edge case: POST bodies are encrypted or non-JSON → report "GraphQL BFF detected but operation names could not be extracted"
+- Edge case: Only 1-2 POST requests to `/graphql` (below 50% threshold) → don't classify as BFF, treat as regular endpoint
+- Regression: Standard REST API with distinct URL paths → GraphQL detection does not activate
+
+**Verification:**
+- Review sniff-capture.md for the new Step 2a.2.5
+- The step is gated on URL convergence (>50% same POST endpoint)
+- Operation names are recorded in the discovery report
+
+## System-Wide Impact
+
+- **Interaction graph:** Unit 1 affects `cleanSpecName` which is called by the OpenAPI parser during `printing-press generate`. Unit 2 and 3 affect the sniff skill instructions which are read by Claude during `/printing-press` runs.
+- **Error propagation:** Unit 1 has no new error paths. Units 2-3 add new skill instruction branches that are gated and have explicit fallback paths (skip if condition not met).
+- **State lifecycle risks:** None — Unit 1 is a pure function change; Units 2-3 are skill instruction changes with no persistent state.
+- **API surface parity:** None — no exported APIs change.
+- **Unchanged invariants:** The generator templates (`auth_browser.go.tmpl`, `auth_simple.go.tmpl`), specgen.go, and the main SKILL.md Phase 1.6/1.7 flow are not modified. The downstream pipeline from spec → generated CLI is unchanged.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Auth page URLs are site-specific (not all sites use `/account`) | Use common patterns + derive from research brief. If no auth pages found, report and continue |
+| GraphQL POST body interception may fail on some sites (CORS, CSP) | Gated on successful extraction. Falls back to "BFF detected but operations not extracted" |
+| Apostrophe stripping may affect edge cases (e.g., `O'Brien's API`) | Test with possessive and Irish-name patterns. The stripping only removes the character, doesn't change surrounding text |
+
+## Sources & References
+
+- **Origin document:** [docs/retros/2026-04-03-dominos-retro.md](docs/retros/2026-04-03-dominos-retro.md)
+- Related plan: [docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md](docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md) — downstream pipeline
+- `internal/openapi/parser.go:1898-1967` — `cleanSpecName` function
+- `skills/printing-press/references/sniff-capture.md` — sniff capture procedure
+- `internal/websniff/specgen.go:318-334` — cookie auth handling in specgen
diff --git a/docs/plans/2026-04-03-002-feat-composed-cookie-auth-plan.md b/docs/plans/2026-04-03-002-feat-composed-cookie-auth-plan.md
new file mode 100644
index 00000000..da00fc8d
--- /dev/null
+++ b/docs/plans/2026-04-03-002-feat-composed-cookie-auth-plan.md
@@ -0,0 +1,326 @@
+---
+title: "feat: Composed cookie auth for sniff-discovered APIs"
+type: feat
+status: active
+date: 2026-04-03
+origin: docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md
+---
+
+# feat: Composed cookie auth for sniff-discovered APIs
+
+## Overview
+
+When the sniff discovers an API that uses a custom Authorization header composed from browser cookie values (e.g., `PagliacciAuth {customerId}|{authToken}`), the printed CLI should support `auth login --chrome` that reads the specific named cookies, composes the header, and saves it — so the user never sees the format.
+
+## Problem Frame
+
+The sniff pipeline now discovers custom auth headers (Step 2a.1.5 item 5) and traces values back to cookies, but the spec format can't represent this, the generator has no template for it, and the printed CLI falls back to generic "paste your token" auth. This feature threads the composed pattern through the entire pipeline: spec → generator → printed CLI runtime. (see origin: `docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md`)
+
+## Requirements Trace
+
+- R1. `AuthConfig` supports `Type: "composed"`
+- R2. `AuthConfig.Format` carries the header template with `{cookieName}` placeholders
+- R3. `AuthConfig.Cookies` (new field) lists cookie names to extract
+- R4. `AuthConfig.CookieDomain` carries the domain (already exists)
+- R5. Generator selects composed auth template when `Auth.Type == "composed"`
+- R6. `auth login --chrome` reads specific named cookies, composes the header, saves to config
+- R7-R8. `auth status` and `auth logout` work for composed auth
+- R9. 401 fails with login URL + `auth login --chrome` command (no prompting)
+- R10. `doctor` checks for cookie extraction tool
+- R11. README documents `auth login --chrome`
+- R12-R14. Sniff traces header values → cookie names → format string → spec
+- R15. No credential flow — browser is auth source of truth
+- R16. Same cookie extraction tools as existing `auth_browser.go.tmpl`
+- R17-R18. Composed header saved to config, attached via `Auth.Header`
+- R19. Missing cookies → print login URL, exit non-zero
+
+## Scope Boundaries
+
+- No credential-based login — Chrome import only
+- No auto-retry on 401 — fail with guidance
+- macOS Chrome only for v1
+- Does not change existing `cookie` auth type behavior
+- Sniff pipeline changes are skill instruction changes only (no Go binary changes for sniff)
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/spec/spec.go:27-39` — `AuthConfig` struct. Has `Format` (unused for cookie auth), `CookieDomain`, but no `Cookies` field.
+- `internal/generator/generator.go:340-352` — Auth template selection: OAuth2 → `auth.go.tmpl`, cookie → `auth_browser.go.tmpl`, else → `auth_simple.go.tmpl`.
+- `internal/generator/templates/auth_browser.go.tmpl` — 559 lines. Cookie extraction (detectCookieTool, extractViaPycookiecheat, discoverChromeProfiles, resolveChromeProfile) is ~400 lines of reusable infrastructure. The pycookiecheat path returns `map[string]string` keyed by cookie name — perfect for picking named cookies.
+- `internal/generator/templates/config.go.tmpl:129-139` — AuthHeader() for cookie type returns `c.AccessToken` directly. Does not use `Format` field.
+- `internal/generator/templates/doctor.go.tmpl:42-69` — Cookie tool availability check.
+- `internal/generator/templates/readme.md.tmpl:69-87` — Cookie auth Quick Start section.
+- `internal/generator/templates/client.go.tmpl:336-346` — Auth header attachment. Uses `Auth.Header` for header name (already supports custom names like `Authorization`).
+- `internal/websniff/specgen.go:318-335` — Sets `Auth.Type: "cookie"` from captures.
+
+### Key Insight: Extend auth_browser.go.tmpl, Don't Duplicate
+
+The existing `auth_browser.go.tmpl` has ~400 lines of cookie extraction infrastructure (tool detection, Chrome profile discovery, cookie extraction via pycookiecheat/cookies/cookie-scoop). The composed auth path differs only in the *post-extraction* step (~20 lines): instead of joining all cookies into a string, it picks specific named cookies and fills a format template.
+
+Duplicating the entire 559-line template for ~20 lines of different logic is wrong. Instead, extend the existing template with Go template conditionals.
+
+## Key Technical Decisions
+
+- **Extend `auth_browser.go.tmpl` rather than create a new template:** The cookie extraction infrastructure is identical. Add conditional logic within the template for the composed path. The generator still selects the same `auth_browser.go.tmpl` for both `cookie` and `composed` types. Rationale: ~400 lines of shared code, only ~20 lines differ.
+- **`Cookies []string` as a new field on AuthConfig:** No existing field serves this purpose. A simple string slice with YAML tag `cookies` is the right representation.
+- **Config stores the composed header value, not individual cookies:** After composition, the result (`"PagliacciAuth 2432962|FD44DA6A..."`) is stored as `AccessToken` in config — same as the cookie type. The composition happens at `auth login --chrome` time, not at request time. Rationale: simpler client code, no need to re-compose on every request.
+- **Format string replacement is literal, not regex:** `strings.ReplaceAll(format, "{cookieName}", cookieValue)` for each named cookie. No special character escaping needed because values are literal strings from the browser's cookie store.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Cookies field representation:** New `Cookies []string` field on `AuthConfig`. YAML: `cookies: [customerId, authToken]`. Simple, matches how it appears in the requirements doc.
+- **Code sharing between cookie and composed:** Same template (`auth_browser.go.tmpl`) with conditional branches. Generator selects `auth_browser.go.tmpl` for both `Auth.Type == "cookie"` and `Auth.Type == "composed"`.
+- **Special characters in cookie values:** pycookiecheat returns decoded values. Format string replacement uses `strings.ReplaceAll` (literal match). No regex, no escaping issues.
+
+### Deferred to Implementation
+
+- Exact placement of the conditional branches within auth_browser.go.tmpl — depends on the template's current structure which may change on the branch.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+auth_browser.go.tmpl decision flow:
+
+ auth login --chrome
+ │
+ ├── detectCookieTool() ← shared
+ ├── resolveChromeProfile() ← shared
+ ├── extractCookies() ← shared (returns map[string]string)
+ │
+ ├── IF Auth.Type == "cookie":
+ │ Join all cookies → "k1=v1; k2=v2; ..."
+ │ Save as AccessToken
+ │
+ └── IF Auth.Type == "composed":
+ For each cookie in Auth.Cookies:
+ Look up value in extracted map
+ If missing → error "cookie {name} not found"
+ Replace {name} placeholders in Auth.Format
+ Save composed header as AccessToken
+
+ config.AuthHeader():
+ cookie: return AccessToken (raw cookie string)
+ composed: return AccessToken (pre-composed header value)
+
+ client.go.tmpl:
+ cookie: req.Header.Set("Cookie", authHeader)
+ composed: req.Header.Set("Authorization", authHeader)
+ ↑ Already handled by Auth.Header field
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Add Cookies field to AuthConfig**
+
+**Goal:** The spec struct can represent which named cookies to extract for composed auth.
+
+**Requirements:** R1, R3
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Test: `internal/spec/spec_test.go`
+
+**Approach:**
+- Add `Cookies []string` field to `AuthConfig` with YAML/JSON tags `cookies`
+- Update the `Type` comment to include `composed` in the enum list
+- Add a test case that parses a spec YAML with `type: composed`, `format`, `cookies`, and `cookie_domain` and verifies all fields are populated
+
+**Patterns to follow:**
+- Existing field style in `AuthConfig` (e.g., `Scopes []string`)
+
+**Test scenarios:**
+- Happy path: Parse YAML with `auth.type: composed, auth.cookies: [customerId, authToken]` → AuthConfig.Cookies == ["customerId", "authToken"]
+- Happy path: Parse YAML with `auth.type: cookie` (no cookies field) → AuthConfig.Cookies is nil (regression)
+- Edge case: Parse YAML with empty cookies list → AuthConfig.Cookies == []
+
+**Verification:**
+- `go test ./internal/spec/` passes with new test cases
+- Existing spec tests unchanged
+
+- [ ] **Unit 2: Extend generator to select auth_browser.go.tmpl for composed type**
+
+**Goal:** The generator routes `Auth.Type == "composed"` to the same template as `cookie`, so the template can handle both.
+
+**Requirements:** R5
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/generator/generator.go` (auth template selection ~line 347)
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Change the condition at line 347 from `g.Spec.Auth.Type == "cookie"` to `g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed"`
+- Add a generator test that creates a spec with `Auth.Type: "composed"` and verifies the generated CLI compiles and has `auth login --chrome`
+
+**Patterns to follow:**
+- Existing generator tests that verify template selection (search for `auth_browser` or `CookieDomain` in generator_test.go)
+
+**Test scenarios:**
+- Happy path: Generate with `Auth.Type: "composed"` → auth.go file exists, contains "chrome" flag
+- Regression: Generate with `Auth.Type: "cookie"` → same behavior as before
+- Regression: Generate with `Auth.Type: "api_key"` → uses auth_simple.go.tmpl
+
+**Verification:**
+- `go test ./internal/generator/` passes
+- Generated CLI for composed auth compiles with `go build ./...`
+
+- [ ] **Unit 3: Extend auth_browser.go.tmpl for composed cookie logic**
+
+**Goal:** The template handles both `cookie` (full cookie string) and `composed` (named cookies → format string) auth paths.
+
+**Requirements:** R6, R7, R8, R9, R16, R17, R19
+
+**Dependencies:** Units 1, 2
+
+**Files:**
+- Modify: `internal/generator/templates/auth_browser.go.tmpl`
+
+**Approach:**
+- In `newAuthLoginCmd`, after `extractCookies()` returns the cookie string, add a conditional:
+ - If `Auth.Type == "composed"`: parse the cookie string back into a map (split on `; `, split each on `=`), look up each cookie in `Auth.Cookies`, replace `{name}` placeholders in `Auth.Format`, save the composed value
+ - If `Auth.Type == "cookie"`: existing behavior (save the raw string)
+- In the "cookies not found" error path: for composed, report which specific cookie was missing and print the login URL from CookieDomain
+- In `newAuthStatusCmd`: add composed-specific status display showing the format and domain
+- The `newAuthLogoutCmd` needs no change (already clears AccessToken)
+
+**Key detail:** `extractViaPycookiecheat` already returns `name=value; name2=value2` format. Parsing this back to a map is straightforward. The `cookies` CLI and `cookie-scoop` return the same format.
+
+**Patterns to follow:**
+- Existing Go template conditionals in the file: `{{- if eq .Auth.Type "cookie"}}` patterns
+
+**Test scenarios:**
+- Happy path: Generate CLI with composed auth → `auth login --chrome` extracts named cookies, composes "PagliacciAuth {id}|{token}", saves to config
+- Happy path: `auth status` for composed auth shows "composed" source and domain
+- Error path: Named cookie not found in extracted set → error names the missing cookie and prints login URL
+- Edge case: Cookie value contains `|` or `=` characters → format string composed correctly (literal replacement)
+- Regression: Generate CLI with cookie auth → `auth login --chrome` still saves full cookie string
+
+**Verification:**
+- Generated CLI compiles
+- `auth login --chrome --help` shows the chrome flag
+- Template renders without Go template syntax errors for both `cookie` and `composed` types
+
+- [ ] **Unit 4: Extend config.go.tmpl and client.go.tmpl for composed auth**
+
+**Goal:** Config returns the composed header value; client attaches it with the correct header name.
+
+**Requirements:** R17, R18
+
+**Dependencies:** Unit 3
+
+**Files:**
+- Modify: `internal/generator/templates/config.go.tmpl`
+- Modify: `internal/generator/templates/client.go.tmpl` (likely no change needed — verify)
+
+**Approach:**
+- In `config.go.tmpl AuthHeader()`: add a `composed` branch that returns `c.AccessToken` (same as cookie — the composition already happened at login time). Set `AuthSource = "chrome-composed"` to distinguish from "browser" in status output.
+- In `client.go.tmpl`: verify the existing logic already works. `Auth.Header` for composed will be `"Authorization"` (not `"Cookie"`), so `req.Header.Set("Authorization", authHeader)` is correct. No changes expected.
+
+**Patterns to follow:**
+- Existing cookie branch in config.go.tmpl (lines 129-139)
+
+**Test scenarios:**
+- Happy path: Config with composed AccessToken → AuthHeader() returns the composed string
+- Happy path: Client sets `Authorization: PagliacciAuth ...` header (not `Cookie:`)
+- Regression: Config with cookie AccessToken → AuthHeader() returns raw cookie string
+
+**Verification:**
+- Generated CLI's config and client code compiles
+- `Auth.Header` correctly controls the HTTP header name for composed type
+
+- [ ] **Unit 5: Extend doctor.go.tmpl and readme.md.tmpl for composed auth**
+
+**Goal:** Doctor checks for cookie tools; README documents the auth flow.
+
+**Requirements:** R10, R11
+
+**Dependencies:** Unit 3
+
+**Files:**
+- Modify: `internal/generator/templates/doctor.go.tmpl`
+- Modify: `internal/generator/templates/readme.md.tmpl`
+
+**Approach:**
+- In `doctor.go.tmpl`: extend the existing cookie-type condition to also trigger for composed. `{{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}` — the cookie tool check is identical.
+- In `readme.md.tmpl`: add a composed section that explains "This CLI uses your browser session" and shows `auth login --chrome`. Nearly identical to the cookie section but can mention the specific domain.
+
+**Patterns to follow:**
+- Existing cookie sections in both templates
+
+**Test scenarios:**
+- Happy path: Doctor for composed CLI → checks for pycookiecheat, reports availability
+- Happy path: README for composed CLI → Quick Start includes `auth login --chrome`
+- Regression: Doctor for cookie CLI → unchanged behavior
+
+**Verification:**
+- Generated CLI's doctor command runs without error
+- Generated README contains auth documentation
+
+- [ ] **Unit 6: Update sniff skill instructions for composed auth detection**
+
+**Goal:** The sniff pipeline writes `Auth.Type: composed` with format string, cookie names, and domain into the spec when it detects a composed cookie pattern.
+
+**Requirements:** R12, R13, R14, R15
+
+**Dependencies:** Units 1-5 (spec format must exist for the sniff to target it)
+
+**Files:**
+- Modify: `skills/printing-press/references/sniff-capture.md` (Step 2a.1.5 item 5)
+
+**Approach:**
+- In the auth header discovery step (Step 2a.1.5 item 5), after capturing the Authorization header and cookie values, add instructions for Claude to:
+ 1. Match each value in the header against cookie names from `document.cookie`
+ 2. Construct the format string by replacing literal values with `{cookieName}` placeholders
+ 3. Write the composed auth config into the spec YAML that Claude builds:
+ ```yaml
+ auth:
+ type: composed
+ header: Authorization
+ format: "PagliacciAuth {customerId}|{authToken}"
+ cookie_domain: pagliacci.com
+ cookies: [customerId, authToken]
+ ```
+- Add a worked example using the Pagliacci pattern for clarity
+
+**Patterns to follow:**
+- Existing sniff-capture.md instruction style (numbered steps with bash examples)
+
+**Test scenarios:**
+- Happy path: Sniff discovers `Authorization: PagliacciAuth 2432962|FD44DA6A...` + cookies `customerId=2432962, authToken=FD44DA6A...` → spec has `type: composed, format: "PagliacciAuth {customerId}|{authToken}", cookies: [customerId, authToken]`
+- Edge case: Sniff discovers `Authorization: Bearer {token}` where token is in cookie `auth_token` → spec has `type: composed, format: "Bearer {auth_token}", cookies: [auth_token]`
+- Regression: Sniff discovers plain cookie auth (Cookie header with full string) → spec has `type: cookie` (not composed)
+
+**Verification:**
+- The sniff-capture.md instructions are clear enough that Claude produces the correct spec YAML for a composed auth pattern
+
+## System-Wide Impact
+
+- **Interaction graph:** The change touches the spec struct → generator → 4 templates → sniff skill. Each layer passes data to the next. No callbacks or middleware affected.
+- **Error propagation:** Auth errors (missing cookies, missing tool) propagate as non-zero exit codes with human-readable messages. No new error types needed — extends existing patterns.
+- **API surface parity:** The `auth` subcommands (`login --chrome`, `status`, `logout`) behave identically to the existing cookie type — same flags, same output patterns, just different internal composition.
+- **Unchanged invariants:** Existing `cookie` auth type behavior is unchanged. The `api_key`, `bearer_token`, and `oauth2` paths are untouched. The `Auth.Format` field, though now used for composed auth, was previously unused for cookie auth — no existing behavior changes.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Template conditional complexity — auth_browser.go.tmpl grows with two code paths | The divergence is small (~20 lines). If it grows beyond that, extract shared code into template helper functions |
+| Cookie extraction tools may not be installed | Same mitigation as existing cookie auth — doctor warns, auth login errors with install instructions |
+| Sniff may not reliably trace header values to cookies (URL encoding, partial matches) | The sniff instructions include fallback: if cookie matching fails, report the auth scheme without composed config. The CLI falls back to generic token auth |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md](docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md)
+- Related plan: [docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md](docs/plans/2026-04-02-001-feat-browser-auth-cookie-runtime-plan.md) — the original cookie auth plan that built auth_browser.go.tmpl
+- Existing template: `internal/generator/templates/auth_browser.go.tmpl`
+- Spec struct: `internal/spec/spec.go:27-39`
+- Generator selection: `internal/generator/generator.go:340-352`
diff --git a/docs/retros/2026-04-03-dominos-retro.md b/docs/retros/2026-04-03-dominos-retro.md
new file mode 100644
index 00000000..d89d301d
--- /dev/null
+++ b/docs/retros/2026-04-03-dominos-retro.md
@@ -0,0 +1,204 @@
+# Printing Press Retro: Domino's Pizza
+
+## Session Stats
+- API: Domino's Pizza (unofficial REST API + GraphQL BFF)
+- Spec source: Sniffed (browser sniff + community wrapper research, hand-built OpenAPI spec)
+- Scorecard: 84/100 Grade A
+- Verify pass rate: 92% (12/13)
+- Fix loops: 1
+- Manual code edits: 3 (root description, dead code removal, config fields)
+- Features built from scratch: 4 (stores.go, menu.go, order.go, track.go — 640 lines)
+
+## Findings
+
+### 1. Sniff doesn't explore authenticated endpoints when AUTH_SESSION_AVAILABLE (skill instruction gap)
+
+- **What happened:** User confirmed they're logged in to Domino's. Sniff connected to Chrome and captured traffic. But the user-flow plan only walked the anonymous ordering flow (homepage → order page). It never visited account pages (order history, rewards, saved addresses, profile) — the authenticated surface was entirely missed. The CLI ships without order history, rewards, or saved address commands.
+- **Root cause:** The skill's sniff-capture.md Step 2a.1 says "After the primary flow, add 1-2 secondary flows from the research brief's other top workflows (e.g., 'Check rewards,' 'Track an order')." But this is a suggestion, not a gate. When `AUTH_SESSION_AVAILABLE=true`, there is no instruction to specifically browse authenticated pages. Claude followed the primary flow plan and stopped.
+- **Cross-API check:** Any API with user accounts has an authenticated surface: Notion (workspace settings, billing), Linear (user profile, org settings), Stripe (dashboard data). When a user says "I'm logged in," the sniff should always explore what that unlocks. This applies across all input methods — browser sniff is the only way to discover cookie-authenticated endpoints.
+- **Frequency:** Most APIs — any service with user accounts and browser-session auth.
+- **Fallback if machine doesn't fix it:** Claude must remember to browse account pages manually. Reliability: sometimes — Claude followed the skill's flow plan, which didn't include auth pages. The Domino's run proves Claude doesn't catch this reliably.
+- **Worth a machine fix?** Yes. This is the core value proposition of the chrome-auth-sniff feature — without it, authenticated endpoints are systematically missed.
+- **Inherent or fixable:** Fixable. The sniff-capture.md needs an explicit "authenticated flow" section that activates when `AUTH_SESSION_AVAILABLE=true`.
+- **Durable fix:** Add to sniff-capture.md Step 2a.1:
+ ```
+ When AUTH_SESSION_AVAILABLE=true, add authenticated flows AFTER the primary flow:
+ 1. Browse to account/profile page (e.g., /account, /profile, /my-account)
+ 2. Browse to order history page (e.g., /orders, /order-history)
+ 3. Browse to saved addresses / payment methods page
+ 4. Browse to rewards / loyalty page
+ These pages trigger API calls that are only visible with session cookies.
+ Compare endpoints discovered on auth pages vs public pages to classify
+ which endpoints require authentication.
+ ```
+ Condition: `AUTH_SESSION_AVAILABLE=true`
+ Guard: Skip when sniffing anonymously
+- **Test:** Sniff a site where user confirmed login → verify auth pages are in the flow plan and auth-only endpoints appear in the discovery report.
+- **Evidence:** Domino's sniff discovered only 2 pages (homepage, order page) despite user confirming logged-in session. Order history, rewards, saved addresses never visited.
+
+### 2. Sniff doesn't inspect GraphQL POST bodies (skill instruction gap)
+
+- **What happened:** Sniff discovered 9 POST requests to `/api/web-bff/graphql` but never inspected the request bodies to extract operation names. The GraphQL operations were discarded as "not inspected." This means the sniff can't distinguish between different GraphQL operations or understand the API surface of a GraphQL BFF.
+- **Root cause:** The sniff-capture.md focuses on REST endpoint URL patterns. There's no procedure for GraphQL discovery: extracting `operationName` from POST bodies, classifying operations by type (query vs mutation), or building a spec from GraphQL operations.
+- **Cross-API check:** GraphQL BFFs are increasingly common. Notion uses one. Linear uses GraphQL natively. Shopify, GitHub, and many modern platforms use GraphQL. Any SPA that routes all API calls through a single `/graphql` or `/api/graphql` endpoint will hit this gap.
+- **Frequency:** API subclass: GraphQL BFF — estimated 20-30% of modern web apps.
+- **Fallback if machine doesn't fix it:** Claude must manually intercept POST bodies, parse operationName fields, and build the spec. Reliability: never done automatically — Claude didn't even attempt it in this run.
+- **Worth a machine fix?** Yes. GraphQL BFFs are common enough that the sniff needs a procedure for them.
+- **Inherent or fixable:** Fixable. The sniff needs a "GraphQL detection" step.
+- **Durable fix:** Add to sniff-capture.md after Step 2a.2:
+ ```
+ Step 2a.2.5: GraphQL BFF detection
+ If all or most captured XHR URLs resolve to the same POST endpoint
+ (e.g., /api/graphql, /graphql, /api/web-bff/graphql):
+ 1. Classify as GraphQL BFF
+ 2. For each captured POST request to that endpoint:
+ - Extract the request body (use agent-browser network request <id>
+ or browser-use eval to intercept fetch)
+ - Parse the operationName and query fields
+ - Record: operationName, variables schema, response shape
+ 3. Build the spec from GraphQL operations instead of REST paths
+ 4. Group operations by domain (e.g., store*, menu*, order*, account*)
+
+ For agent-browser: use `network requests --type xhr --json` and then
+ `network request <id> --json` to get POST bodies.
+ For browser-use: inject a fetch interceptor before browsing:
+ browser-use eval "const _origFetch=window.fetch;window.fetch=async(...a)=>{const r=await _origFetch(...a);if(a[0]?.includes?.('graphql'))console.log(JSON.stringify({url:a[0],body:a[1]?.body}));return r;}"
+ ```
+ Condition: Multiple POST requests to the same URL path
+ Guard: Skip for standard REST APIs with distinct URL paths
+- **Test:** Sniff a GraphQL BFF site → verify operations are extracted from POST bodies and appear in the spec.
+- **Evidence:** 9 GraphQL operations captured by URL but zero operation names extracted.
+
+### 3. Cookie auth classification missing from sniff → spec pipeline (skill instruction gap + binary gap)
+
+- **What happened:** User is logged in. Sniff connected to Chrome with cookies. But the spec was hand-built from community wrapper knowledge with `Auth.Type` unset. The generator's cookie auth template (`auth_browser.go.tmpl`) was never triggered. The generated CLI has no `auth login --chrome` command despite the infrastructure existing.
+- **Root cause:** Two gaps:
+ 1. **Skill gap:** Step 2d (cookie auth validation) only runs after endpoints are discovered. But the Domino's sniff skipped authenticated endpoints entirely (Finding #1), so Step 2d had nothing to validate.
+ 2. **Pipeline gap:** The hand-built spec didn't include auth config. When the spec is built manually (not by `printing-press sniff --har`), there's no mechanism to inject `Auth.Type: cookie` and `CookieDomain`.
+- **Cross-API check:** Every cookie-authenticated API hits this if the sniff doesn't discover auth endpoints. The specgen already handles cookie auth when it comes through the HAR pipeline — the gap is in the manual/partial sniff path.
+- **Frequency:** API subclass: cookie-authenticated websites — most websites with user accounts.
+- **Fallback if machine doesn't fix it:** Claude must manually set Auth.Type in the spec. Reliability: never — Claude didn't do it in this run because there was no signal to trigger it.
+- **Worth a machine fix?** Yes. The chrome-auth-sniff branch built the downstream infrastructure but the upstream discovery path doesn't feed it.
+- **Inherent or fixable:** Fixable. Fixing Finding #1 (auth flow in sniff) feeds auth endpoints into the discovery. Then Step 2d validates cookie replay. Then the spec gets `Auth.Type: cookie`. Then the generator emits `auth_browser.go.tmpl`.
+- **Durable fix:** This is primarily fixed by Finding #1 (discover auth endpoints) + ensuring Step 2d runs when auth endpoints are found. The spec-building step (whether `printing-press sniff --har` or manual) must propagate auth config. For the manual path: the skill should instruct Claude to add auth fields to hand-built specs when `AUTH_SESSION_AVAILABLE=true` and auth endpoints were discovered.
+- **Test:** Sniff a cookie-auth site while logged in → generated CLI has `auth login --chrome` command.
+- **Evidence:** User confirmed login. Generator has `auth_browser.go.tmpl`. Generated CLI has no `auth login --chrome`.
+
+### 4. User-friendly command aliases built by hand every time (template gap)
+
+- **What happened:** The generator produces commands named after API path segments: `power find-stores`, `orderstorage get-tracker-data`, `tracker-presentation-service track-order`. These are correct but unusable. 640 lines of Go (stores.go, menu.go, order.go, track.go) were written by hand to add `stores find`, `menu browse`, `order place`, `track` aliases.
+- **Root cause:** The generator derives command structure from the spec's path hierarchy. `/power/store-locator` → parent `power`, child `store-locator`. This is mechanical and correct but produces ugly UX. No template exists for user-friendly top-level groupings.
+- **Cross-API check:** Every sniffed API has this problem because path segments are implementation details, not user-facing concepts. Spec-based APIs are better (operationId gives some signal) but still produce `operationId`-derived names that need human mapping. The Steam CLI had the same issue.
+- **Frequency:** Most APIs — especially sniffed ones where path segments are not user-friendly.
+- **Fallback if machine doesn't fix it:** Claude writes alias commands manually. Reliability: always catches it (the ugly names are obvious), but 640 lines per CLI is expensive and the implementations are boilerplate. Claude delegates to a subagent which takes 2+ minutes.
+- **Worth a machine fix?** Yes. This is the most time-consuming manual work in every generation. The generator could emit a command alias mapping.
+- **Inherent or fixable:** Partially fixable. The generator can't always guess the best user-facing grouping, but it can:
+ 1. Detect common resource patterns (store, menu, order, track) and emit top-level aliases
+ 2. Emit alias stubs that Claude fills in (reduces boilerplate from 640 lines to ~100)
+ 3. Accept a `command_aliases` section in the spec extensions that maps path-derived names to user-friendly names
+- **Durable fix:** Medium-term: Add `x-command-aliases` extension support to the spec parser. The skill instruction during Phase 2 would tell Claude to write aliases into the spec before generation. Short-term: the skill instruction can tell Claude to identify the top 4-5 resource groups from the spec and create alias commands following the existing helper patterns. This is still manual but guided.
+ Condition: Always (all APIs benefit from friendlier names)
+ Guard: None needed — aliases are additive, they don't break existing commands
+- **Test:** Generate a CLI from a sniffed spec → verify user-friendly top-level commands exist alongside generated path-based commands.
+- **Evidence:** 640 lines of hand-written alias code across 4 files.
+
+### 5. Local build vs go-install binary mismatch (skill instruction gap)
+
+- **What happened:** The setup contract resolved `printing-press` to `~/go/bin/printing-press` (v0.4.0, installed via `go install`) instead of the local build at `./printing-press` which has unreleased features (polish, workflow-verify, chrome auth). The entire Domino's run used the old binary, missing `polish --remove-dead-code`, `workflow-verify`, and the chrome auth generator template.
+- **Root cause:** The setup contract checked `command -v printing-press` which found the global install first. The local build (kept current by lefthook post-merge hook) was available but not on PATH.
+- **Cross-API check:** Every run from inside the printing-press repo hits this if the go-install version is older than the local build.
+- **Frequency:** Every run from inside the repo (development workflow).
+- **Worth a machine fix?** Yes — already fixed in this session. Setup contract now prepends `$_scope_dir` to PATH when `./printing-press` exists and `cmd/printing-press/` is present (confirming we're in the repo).
+- **Inherent or fixable:** Fixed. Commit `bb33d2d`.
+- **Durable fix:** Already applied to all 4 skill setup contracts.
+- **Test:** Run any skill from inside the repo → `which printing-press` resolves to `./printing-press`.
+- **Evidence:** `printing-press polish` returned "unknown command" because v0.4.0 doesn't have it.
+
+### 6. Spec title apostrophe mangled CLI name (generator bug)
+
+- **What happened:** Spec title "Domino's Pizza API" → `cleanSpecName()` → `domino-s-pizza` → CLI name `domino-s-pizza-pp-cli`. The apostrophe was converted to a hyphen, producing a bad directory and binary name.
+- **Root cause:** `cleanSpecName()` replaces non-alphanumeric characters with hyphens. Apostrophes in brand names (Domino's, McDonald's, Wendy's) become hyphens.
+- **Cross-API check:** Any API with an apostrophe in the title: Domino's, McDonald's, Lowe's, Macy's, Sam's Club, Kohl's. Common in food/retail APIs.
+- **Frequency:** API subclass: brand names with apostrophes — maybe 5-10% of consumer APIs.
+- **Fallback if machine doesn't fix it:** Claude manually edits the spec title before generation. Reliability: sometimes — Claude noticed it after the fact and regenerated, but it cost a full regeneration cycle.
+- **Worth a machine fix?** Yes. Simple fix: strip apostrophes before hyphenating.
+- **Inherent or fixable:** Fixable.
+- **Durable fix:** In `cleanSpecName()` (internal/generator or internal/naming): strip apostrophes before the general non-alphanumeric-to-hyphen replacement. `"Domino's"` → `"Dominos"` → `"dominos"`.
+ Condition: Spec title contains apostrophe
+ Guard: Only strips apostrophes, not other punctuation (hyphens, dots already handled)
+- **Test:** `cleanSpecName("Domino's Pizza API")` → `"dominos-pizza"` (not `"domino-s-pizza"`).
+- **Evidence:** First generation produced `domino-s-pizza-pp-cli`, required spec edit and full regeneration.
+
+## Prioritized Improvements
+
+### Fix the Scorer
+No scorer bugs identified in this run.
+
+### Do Now
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 6 | Strip apostrophes in cleanSpecName | internal/naming | subclass: brand names | sometimes | small | apostrophe-only |
+| 5 | Local build preference in setup contract | skills/*/SKILL.md | every repo run | never (wrong binary used) | small | already done |
+
+### Do Next (needs design/planning)
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 1 | Auth-aware sniff flow when AUTH_SESSION_AVAILABLE | skills/printing-press/references/sniff-capture.md | most APIs | sometimes | medium | AUTH_SESSION_AVAILABLE gate |
+| 2 | GraphQL BFF operation discovery | skills/printing-press/references/sniff-capture.md | subclass: GraphQL BFF (~25%) | never | medium | same-URL POST detection |
+| 3 | Cookie auth propagation through sniff → spec → generator | sniff-capture.md + spec builder | subclass: cookie-auth sites | never | medium | depends on #1 |
+| 4 | User-friendly command alias generation | generator templates or skill | most APIs | always but expensive (640 lines) | large | all APIs |
+
+### Skip
+| # | Fix | Why unlikely to recur |
+|---|-----|----------------------|
+| (none) | | |
+
+## Work Units
+
+### WU-1: Authenticated Sniff Flow (findings #1, #3)
+- **Goal:** When AUTH_SESSION_AVAILABLE=true, the sniff automatically explores authenticated pages (account, order history, rewards, profile) and classifies endpoints as auth-required vs public. Cookie auth propagates through to the generated spec.
+- **Target files:**
+ - `skills/printing-press/references/sniff-capture.md` — add authenticated flow section to Step 2a.1
+ - `skills/printing-press/SKILL.md` — ensure Phase 1.6 → Phase 1.7 → sniff-capture handoff preserves AUTH_SESSION_AVAILABLE
+- **Acceptance criteria:**
+ - Sniff a cookie-auth site while logged in → auth pages visited, auth-only endpoints in discovery report
+ - Step 2d validates cookie replay → spec gets Auth.Type=cookie, CookieDomain set
+ - Generated CLI has `auth login --chrome` when cookie auth detected
+ - Sniff an anonymous site (no login) → auth flow skipped, no regression
+- **Scope boundary:** Does NOT change the generator templates (auth_browser.go.tmpl already exists). Does NOT change specgen.go (already handles cookie auth from HAR). Only changes the skill instructions to feed the existing pipeline.
+- **Complexity:** medium (skill instruction changes, 2 files, needs careful flow design)
+
+### WU-2: GraphQL BFF Discovery (finding #2)
+- **Goal:** When the sniff detects multiple POST requests to the same URL path (GraphQL BFF pattern), it extracts operation names from request bodies and builds the spec from GraphQL operations instead of REST paths.
+- **Target files:**
+ - `skills/printing-press/references/sniff-capture.md` — add Step 2a.2.5 for GraphQL detection
+- **Acceptance criteria:**
+ - Sniff a GraphQL BFF site → operation names extracted, spec groups operations by domain
+ - Sniff a standard REST API → GraphQL detection skipped, no regression
+- **Scope boundary:** Does NOT add GraphQL schema introspection. Only discovers operations from captured traffic. Does NOT change the generator's command naming (that's WU-3 territory).
+- **Complexity:** medium (skill instruction changes, browser eval for POST body interception)
+
+### WU-3: Strip Apostrophes in cleanSpecName (finding #6)
+- **Goal:** Brand names with apostrophes produce clean CLI names without mangled hyphens.
+- **Target files:**
+ - `internal/naming/naming.go` (or wherever `cleanSpecName` lives — verify with grep)
+- **Acceptance criteria:**
+ - `cleanSpecName("Domino's Pizza API")` → `"dominos-pizza"`
+ - `cleanSpecName("McDonald's API")` → `"mcdonalds"`
+ - `cleanSpecName("Lowe's Home Improvement")` → `"lowes-home-improvement"`
+ - `cleanSpecName("Stripe API")` → `"stripe"` (no regression for names without apostrophes)
+- **Scope boundary:** Only strips apostrophes. Does not change handling of other punctuation.
+- **Complexity:** small (1 file, 1 line change + tests)
+
+## Anti-patterns
+
+- **Declaring "no auth needed" based on the ordering API alone.** The REST ordering pipeline doesn't need auth, but the account/profile surface does. When an API has both public and authenticated surfaces, the sniff must explore both.
+- **Stopping sniff exploration when community wrappers document the public API.** Community wrappers document the anonymous surface. The authenticated surface is often undocumented and only discoverable by sniffing with a logged-in session.
+
+## What the Machine Got Right
+
+- **Community wrapper research was thorough.** Found all major tools (node-dominos-pizza-api, apizza, MCPizza, pizzapi) and extracted accurate endpoint paths. The REST API probe confirmed all endpoints respond correctly.
+- **Spec-from-research worked well.** Building an OpenAPI spec from community-documented endpoints produced a functional CLI that scored 84/100 on first generation. The spec had correct paths, parameters, and response schemas.
+- **Generator quality gates all passed.** 7/7 gates on first try. No build failures.
+- **Rate limiting compliance.** The sniff respected pacing rules and didn't trigger any 429s.
+- **Setup contract local-build fix is clean.** Detects repo context via `cmd/printing-press/` directory, doesn't break standalone installs.
diff --git a/docs/retros/2026-04-03-pagliacci-retro.md b/docs/retros/2026-04-03-pagliacci-retro.md
new file mode 100644
index 00000000..ecda3581
--- /dev/null
+++ b/docs/retros/2026-04-03-pagliacci-retro.md
@@ -0,0 +1,211 @@
+# Printing Press Retro: Pagliacci Pizza
+
+## Session Stats
+- API: Pagliacci Pizza (regional Seattle chain, undocumented Angular SPA + Azure REST API)
+- Spec source: Browser sniff (browser-use with headed login) + JS bundle extraction
+- Scorecard: 84/100 Grade A
+- Verify pass rate: 98% (41/42)
+- Fix loops: 0 (single generation after final spec)
+- Manual code edits: 1 (root description rewrite)
+- Features built from scratch: 0
+- Spec regenerations: 2 (first without auth, second with full auth after headed login)
+
+## Findings
+
+### 1. Claude skipped browser-use three times despite skill instructions saying it's preferred (skill instruction compliance)
+
+- **What happened:** The skill says "Step 2a: browser-use CLI capture (preferred)" but Claude chose different approaches three times across two APIs:
+ 1. **Domino's #1:** Used agent-browser auto-connect instead of browser-use. Auto-connect mode can navigate but can't access DOM via eval/snapshot — the sniff captured zero useful data.
+ 2. **Domino's #2:** Skipped browser-use entirely and went straight to `curl` after extracting endpoints from the JS bundle, bypassing the interactive sniff flow completely.
+ 3. **Pagliacci #1:** Same pattern — extracted 33 endpoints from the Angular bundle via curl, never opened browser-use.
+ The user corrected this three times. A memory entry was created after the third correction.
+- **Root cause:** The sniff-capture.md Step 1d (session transfer) had a recommendation "Chrome running → prefer agent-browser auto-connect" that Claude interpreted as the capture backend choice, not just the session transfer method. For the curl shortcut, there was no explicit instruction saying "don't skip browser-use by curling APIs directly." Claude optimized for speed over procedure compliance.
+- **Cross-API check:** This is a Claude behavior pattern, not an API-specific issue. It will recur on every sniffed API until the skill instructions are unambiguous.
+- **Frequency:** Every sniffed API.
+- **Fallback if machine doesn't fix it:** User has to correct Claude. Reliability: never caught on its own — required explicit user correction all three times.
+- **Worth a machine fix?** Yes. Already partially fixed (commit b337c12 updated sniff-capture.md). But needs stronger guardrails.
+- **Inherent or fixable:** Fixable. The skill instruction was ambiguous. The fix is to make it unambiguous.
+- **Durable fix:** Already applied in commit b337c12: "Session transfer vs capture are separate concerns. Use agent-browser for session transfer only. Always use browser-use for the actual capture." Additionally, add to the sniff-capture.md a cardinal rule at the top: "**NEVER skip browser-use for capture. Do NOT substitute curl probing, JS bundle grepping, or agent-browser auto-connect for a proper browser-use interactive sniff.**"
+- **Test:** Run /printing-press on a website → verify browser-use is used for capture (not agent-browser or curl).
+- **Evidence:** Three corrections across Domino's and Pagliacci sessions.
+
+### 2. Sniff didn't visit authenticated pages — missed the entire account surface (skill instruction gap)
+
+- **What happened:** On the first Pagliacci sniff attempt, Claude used browser-use with --profile "Default" but the session had expired (login page shown). Instead of recognizing this and offering headed login, Claude declared "session expired" and proceeded to build a spec from only the public endpoints. The entire authenticated surface (order history, rewards, saved addresses, stored coupons, customer profile) was missed. The user had to point out "wait why did session expire? you miss the point if we can't do the authenticated calls."
+- **Root cause:** Two gaps: (a) The skill doesn't instruct Claude to verify login state after loading a profile and fallback to headed login if the session expired. (b) Claude treated expired session as "skip auth" rather than "try another auth method."
+- **Cross-API check:** Session expiry is common — cookies expire, tokens rotate. Any site with authentication will hit this if the user's last login was hours/days ago.
+- **Frequency:** Most sniffed APIs with user accounts.
+- **Fallback if machine doesn't fix it:** User catches it. Reliability: sometimes — the user caught it here, but Claude didn't self-correct.
+- **Worth a machine fix?** Yes.
+- **Inherent or fixable:** Fixable. Add a session verification step after profile load.
+- **Durable fix:** Add to sniff-capture.md Step 1d, after loading a Chrome profile:
+ ```
+ After profile loads, verify the session is active:
+ 1. Check for login/sign-in links vs account/profile links on the page
+ 2. If login link is visible (session expired), offer headed login:
+ "Your session has expired. I'll open a visible browser so you can log in."
+ 3. Do NOT skip auth discovery because the profile session expired.
+ ```
+ Condition: AUTH_SESSION_AVAILABLE=true and profile loaded
+ Guard: Skip when anonymous sniff
+- **Test:** Load an expired Chrome profile → verify the skill offers headed login fallback.
+- **Evidence:** "Sign Up / Sign In" visible after --profile load. Claude said "session expired" and moved on.
+
+### 3. Auth header pattern discovery required manual XHR interception (discovered optimization)
+
+- **What happened:** Pagliacci uses a custom auth scheme: `Authorization: PagliacciAuth {customerId}|{authToken}`. This was not discoverable from cookies alone (cookie replay returned 401). Claude had to install an XHR header interceptor, trigger an SPA client-side navigation (to avoid page reload resetting the interceptor), and capture the request headers to discover the pattern. This took 3 attempts.
+- **Root cause:** The sniff-capture.md Step 2d (cookie auth validation) only tests cookie replay. Many APIs use custom Authorization headers derived from cookies, not cookie replay itself. The Angular app reads `customerId` and `authToken` cookies and constructs the `PagliacciAuth` header — a pattern invisible to cookie-only validation.
+- **Cross-API check:** Custom Authorization headers are common. Many SPAs store tokens in cookies/localStorage and add them as Bearer/custom headers. This isn't just Pagliacci — any site where the frontend constructs auth headers from stored tokens will hit this.
+- **Frequency:** API subclass: SPAs with custom auth headers constructed client-side — estimated 30-40% of modern web apps.
+- **Fallback if machine doesn't fix it:** Claude must manually intercept headers. Reliability: sometimes — it took 3 attempts in this session (interceptor reset on navigation twice before Claude used SPA-internal navigation).
+- **Worth a machine fix?** Yes. The XHR header interception pattern should be part of the standard sniff flow, not a manual debug exercise.
+- **Inherent or fixable:** Fixable. Add auth header discovery to the sniff capture procedure.
+- **Durable fix:** Add to sniff-capture.md as a new step between Step 2a.1.5 (auth flow) and Step 2d (cookie validation):
+ ```
+ Step 2a.1.6: Auth header discovery
+ After visiting the first authenticated page, install an XHR/fetch header
+ interceptor and trigger a client-side navigation (click a link, don't
+ use browser-use open which reloads the page):
+
+ browser-use eval "window.__authHeaders={};const _s=XMLHttpRequest.prototype.setRequestHeader;
+ XMLHttpRequest.prototype.setRequestHeader=function(k,v){
+ if(k.toLowerCase()==='authorization')window.__authHeaders[k]=v.substring(0,80);
+ _s.apply(this,arguments)};'OK'"
+
+ Then click a nav link to trigger API calls. Collect:
+ browser-use eval "JSON.stringify(window.__authHeaders)"
+
+ If an Authorization header is found:
+ - Record the scheme (Bearer, PagliacciAuth, custom)
+ - Record the token format
+ - Determine where the token comes from (cookie, localStorage, sessionStorage)
+ - Use this header format in Step 2d validation instead of cookie replay
+ ```
+ Condition: AUTH_SESSION_AVAILABLE=true and authenticated pages visited
+ Guard: Skip when anonymous sniff or when auth is already known (e.g., Bearer token from spec)
+- **Test:** Sniff a site with custom Authorization headers → verify the scheme is captured and used in auth validation.
+- **Evidence:** Cookie replay returned 401. XHR interception revealed `PagliacciAuth 2432962|FD44DA6A...` pattern.
+
+### 4. SPA interceptors reset on page navigation — need SPA-aware interception strategy (skill instruction gap)
+
+- **What happened:** Fetch/XHR interceptors installed via `browser-use eval` were lost every time `browser-use open` navigated to a new URL (full page reload in Next.js/Angular SSR). This happened on both Domino's (GraphQL interceptor lost) and Pagliacci (header interceptor lost twice). The workaround was to use client-side navigation (click links) instead of `browser-use open` to keep the interceptor alive.
+- **Root cause:** The sniff-capture.md doesn't distinguish between page navigation (resets JS context) and SPA navigation (preserves JS context). It instructs `browser-use open` for each page, which does a full navigation.
+- **Cross-API check:** Every SPA (React, Angular, Next.js, Vue) will lose interceptors on full page navigation. Most modern web apps are SPAs.
+- **Frequency:** Every sniffed SPA — which is most modern websites.
+- **Fallback if machine doesn't fix it:** Claude must manually figure out SPA vs full navigation each time. Reliability: sometimes — Claude figured it out on the third attempt for Pagliacci but never on Domino's.
+- **Worth a machine fix?** Yes.
+- **Inherent or fixable:** Fixable. The sniff procedure should use click-based navigation for SPAs instead of browser-use open.
+- **Durable fix:** Add to sniff-capture.md Step 2a.1, after installing interceptors:
+ ```
+ SPA Navigation Rule:
+ After installing interceptors (fetch/XHR), do NOT use `browser-use open`
+ to navigate between pages — it triggers a full page reload which resets
+ the JS context and destroys the interceptors.
+
+ Instead, use click-based SPA navigation:
+ browser-use eval "document.querySelector('a[href*=\"/account\"]').click()"
+ or:
+ browser-use click <element>
+
+ Only use `browser-use open` for the FIRST page load (before interceptors
+ are installed) or when you need to re-install interceptors on a new page.
+
+ After any `browser-use open`, re-install interceptors before proceeding.
+ ```
+- **Test:** Install interceptor → navigate via click → verify interceptor still active. Navigate via `browser-use open` → verify interceptor is gone (negative test that explains the rule).
+- **Evidence:** Domino's GraphQL interceptor captured 0 ops. Pagliacci header interceptor required 3 attempts.
+
+### 5. JS bundle extraction as supplementary discovery technique (discovered optimization)
+
+- **What happened:** Claude extracted 33 API endpoint paths from Pagliacci's Angular main bundle by grepping for route patterns. This revealed endpoints the sniff would have missed (like /MigrateQuestion, /TransferGift, /AccessDevice) because no user flow visits those pages. The bundle is the complete API surface definition for the frontend.
+- **Root cause:** The sniff procedure only discovers endpoints that are actually called during the browsing flow. Endpoints for rarely-used features (account migration, gift transfers, device access) are in the code but never exercised.
+- **Cross-API check:** Every SPA bundles its API configuration. Angular, React, Vue apps all embed endpoint paths in the compiled JS.
+- **Frequency:** Every sniffed SPA.
+- **Worth a machine fix?** Yes — as a supplementary technique alongside browser-use, not a replacement. The sniff discovers response shapes and auth patterns; the bundle extraction discovers the complete endpoint list.
+- **Inherent or fixable:** Fixable. Add bundle extraction as a supplementary step.
+- **Durable fix:** Add to sniff-capture.md after Step 2a.2 (URL collection):
+ ```
+ Step 2a.2.3: JS bundle endpoint extraction (supplementary)
+ After collecting URLs from browsing, also extract endpoints from the
+ main JS bundle as a coverage supplement:
+
+ 1. Find the main bundle: browser-use eval to get script[src] elements
+ matching the site domain and containing 'main' or 'app' in the filename
+ 2. curl the bundle and search for API path patterns:
+ - String literals matching /[A-Z][a-zA-Z]+(/[A-Z][a-zA-Z]+)*
+ - apiUrl/baseUrl + concatenated path strings
+ - HTTP method calls with path arguments
+ 3. Merge bundle-discovered endpoints with sniff-discovered endpoints
+ 4. Mark bundle-only endpoints as "discovered: bundle" vs "discovered: sniff"
+
+ This is supplementary — the sniff remains the primary discovery method
+ because it provides response shapes, auth patterns, and parameter types.
+ Bundle extraction only gives endpoint paths.
+ ```
+ Condition: SPA detected (Angular/React/Vue indicators in page source)
+ Guard: Skip for non-SPA sites (server-rendered HTML without JS bundles)
+- **Test:** Sniff a SPA → verify bundle extraction finds additional endpoints not captured during browsing.
+- **Evidence:** Bundle extraction found /MigrateQuestion, /TransferGift, /AccessDevice, /PasswordForgot — never visited during sniff.
+
+## Prioritized Improvements
+
+### Fix the Scorer
+No scorer bugs identified in this run.
+
+### Do Now
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 1 | Add cardinal rule: never skip browser-use for capture | sniff-capture.md | every sniff | never (corrected 3x) | small | none |
+| 2 | Add session expiry detection + headed login fallback | sniff-capture.md | most auth APIs | sometimes | small | AUTH_SESSION_AVAILABLE gate |
+| 4 | Add SPA navigation rule for interceptor preservation | sniff-capture.md | every SPA | sometimes | small | none |
+
+### Do Next (needs design/planning)
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 3 | Add auth header discovery (XHR interception) | sniff-capture.md | subclass: custom auth headers (~35%) | sometimes | medium | auth flow active |
+| 5 | Add JS bundle endpoint extraction as supplementary | sniff-capture.md | every SPA | never (manual grep) | medium | SPA detection |
+
+### Skip
+(none)
+
+## Work Units
+
+### WU-1: Sniff capture robustness (findings #1, #2, #4)
+- **Goal:** The sniff capture procedure reliably uses browser-use, detects expired sessions, and preserves interceptors during SPA navigation.
+- **Target files:**
+ - `skills/printing-press/references/sniff-capture.md`
+- **Acceptance criteria:**
+ - Cardinal rule at top of file: "NEVER skip browser-use for capture"
+ - After profile load: session verification check, headed login fallback on expiry
+ - SPA navigation rule: use click-based navigation after installing interceptors, re-install after any browser-use open
+ - Negative test: anonymous sniff → session check skipped, headed login not offered
+- **Scope boundary:** Does NOT change browser-use or agent-browser tools. Skill instruction changes only.
+- **Complexity:** small (1 file, 3 additions to existing steps)
+
+### WU-2: Auth header discovery and bundle extraction (findings #3, #5)
+- **Goal:** The sniff automatically discovers custom Authorization headers from XHR interception and supplements endpoint discovery with JS bundle extraction.
+- **Target files:**
+ - `skills/printing-press/references/sniff-capture.md`
+- **Acceptance criteria:**
+ - After visiting auth pages: XHR header interceptor captures Authorization scheme
+ - Auth scheme propagated to cookie/token validation step
+ - After sniff browsing: main JS bundle scanned for additional endpoint paths
+ - Bundle-only endpoints marked as "discovered: bundle" in the report
+- **Scope boundary:** Does NOT change the generator or spec parser. Skill instruction changes only.
+- **Dependencies:** WU-1 (session verification) should complete first so auth pages are actually visited.
+- **Complexity:** medium (1 file, 2 new steps with interception logic)
+
+## Anti-patterns
+
+- **Shortcutting the sniff procedure.** Extracting endpoints from JS bundles or curling APIs directly feels faster but skips the interactive discovery that reveals auth patterns, response shapes, and real user flows. The sniff procedure exists for a reason — follow it, then supplement with bundle extraction.
+- **Treating expired session as "skip auth."** When a Chrome profile's session has expired, the correct response is to offer an alternative auth method (headed login), not to proceed without auth.
+- **Using `browser-use open` for SPA navigation after installing interceptors.** Full page navigation resets the JS context. Use click-based SPA navigation to keep interceptors alive.
+
+## What the Machine Got Right
+
+- **Setup contract local build preference.** The new setup contract correctly detected the repo and used the local binary with all unreleased features. No version mismatch issues.
+- **browser-use --profile for Chrome cookie inheritance.** When Chrome was closed, loading the Default profile worked correctly — all cookies were available (the session was expired but the mechanism was sound).
+- **browser-use eval for DOM access.** Full DOM inspection, link discovery, cookie reading, and interceptor installation all worked via eval. This is a massive advantage over agent-browser's auto-connect mode.
+- **Headed login flow.** When the session was expired, the headed login workaround (open visible browser → user logs in → continue sniff) worked perfectly. The auth cookies were captured and the authenticated surface was fully discovered.
+- **Auth header interception via SPA navigation.** Once Claude used click-based navigation instead of `browser-use open`, the XHR header interceptor captured the custom PagliacciAuth scheme correctly.
+- **98% verify pass rate on first generation.** The spec was accurate enough that 41/42 commands passed all 3 verify checks without any fix loops.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 97d35f4c..ba3745aa 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -339,11 +339,13 @@ func (g *Generator) Generate() error {
}
// Always render auth command - use full OAuth2 template when authorization URL is present,
- // otherwise use simple token-management template
+ // browser cookie template for cookie-auth APIs, otherwise simple token-management template
authPath := filepath.Join("internal", "cli", "auth.go")
authTmpl := "auth_simple.go.tmpl"
if g.Spec.Auth.AuthorizationURL != "" {
authTmpl = "auth.go.tmpl"
+ } else if g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" {
+ authTmpl = "auth_browser.go.tmpl"
}
if err := g.renderTemplate(authTmpl, authPath, g.Spec); err != nil {
return fmt.Errorf("rendering auth: %w", err)
@@ -969,19 +971,36 @@ func (g *Generator) exampleLine(commandPath, endpointName string, endpoint spec.
func flagName(name string) string {
name = strings.TrimLeft(name, "$")
- // Replace common separators with hyphens, strip anything not alphanumeric or hyphen
+ // Convert camelCase/PascalCase and separators to kebab-case.
+ // "pageSize" → "page-size", "storeID" → "store-id", "per_page" → "per-page"
var b strings.Builder
- lastHyphen := true
- for _, r := range name {
- if unicode.IsLetter(r) || unicode.IsDigit(r) {
- b.WriteRune(unicode.ToLower(r))
- lastHyphen = false
- } else if !lastHyphen && b.Len() > 0 {
- b.WriteByte('-')
- lastHyphen = true
+ runes := []rune(name)
+ for i, r := range runes {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
+ // Non-alphanumeric → hyphen (dedup'd below)
+ if b.Len() > 0 {
+ b.WriteByte('-')
+ }
+ continue
}
+ // Insert hyphen at camelCase boundaries: lowercase→uppercase
+ if i > 0 && unicode.IsUpper(r) {
+ prev := runes[i-1]
+ if unicode.IsLower(prev) || unicode.IsDigit(prev) {
+ b.WriteByte('-')
+ } else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
+ // Handle acronyms: "storeID" → "store-id" (not "store-i-d")
+ b.WriteByte('-')
+ }
+ }
+ b.WriteRune(unicode.ToLower(r))
+ }
+ // Collapse multiple hyphens and trim
+ result := b.String()
+ for strings.Contains(result, "--") {
+ result = strings.ReplaceAll(result, "--", "-")
}
- return strings.Trim(b.String(), "-")
+ return strings.Trim(result, "-")
}
func safeTypeName(name string) string {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index d7ddbe1a..3df8fe33 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1207,3 +1207,115 @@ func TestGeneratedHelpers_DeadCodeRemoved(t *testing.T) {
assert.Contains(t, content, "filterFields")
assert.Contains(t, content, "classifyAPIError")
}
+
+func TestGenerate_CookieAuthUsesBrowserTemplate(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "cookieapp",
+ Version: "0.1.0",
+ BaseURL: "https://app.example.com",
+ Auth: spec.AuthConfig{
+ Type: "cookie",
+ Header: "Cookie",
+ In: "cookie",
+ CookieDomain: ".example.com",
+ EnvVars: []string{"COOKIEAPP_COOKIES"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/cookieapp-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "items": {
+ Description: "Manage items",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/api/items", Description: "List items"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "cookieapp-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ authGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+ require.NoError(t, err)
+ content := string(authGo)
+
+ // Browser auth template indicators
+ assert.Contains(t, content, "--chrome")
+ assert.Contains(t, content, "detectCookieTool")
+ assert.Contains(t, content, "extractCookies")
+ assert.Contains(t, content, "cookieToolSupportsProfiles")
+ assert.Contains(t, content, "--url")
+ assert.Contains(t, content, "does not support --profile")
+ assert.Contains(t, content, ".example.com")
+ assert.Contains(t, content, "continuing without auto-detection")
+ // Should NOT contain simple token template indicators
+ assert.NotContains(t, content, "set-token")
+
+ // Config should have cookie branch in AuthHeader
+ configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ configContent := string(configGo)
+ assert.Contains(t, configContent, `"browser"`)
+
+ // Doctor should reference browser auth
+ doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+ require.NoError(t, err)
+ doctorContent := string(doctorGo)
+ assert.Contains(t, doctorContent, "auth login --chrome")
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
+
+func TestGenerate_ComposedAuthUsesBrowserTemplate(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "pagliacci",
+ Version: "0.1.0",
+ BaseURL: "https://pag-api.azurewebsites.net/api",
+ Auth: spec.AuthConfig{
+ Type: "composed",
+ Header: "Authorization",
+ Format: "PagliacciAuth {customerId}|{authToken}",
+ CookieDomain: "pagliacci.com",
+ Cookies: []string{"customerId", "authToken"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/pagliacci-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "store": {
+ Description: "Manage stores",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/Store", Description: "List stores"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "pagliacci-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ authGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+ require.NoError(t, err)
+ content := string(authGo)
+
+ // Should use browser auth template (shared with cookie type)
+ assert.Contains(t, content, "--chrome")
+ assert.Contains(t, content, "detectCookieTool")
+ assert.Contains(t, content, "extractCookies")
+ assert.Contains(t, content, "pagliacci.com")
+ // Should NOT contain simple token template
+ assert.NotContains(t, content, "set-token")
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
new file mode 100644
index 00000000..266f0b28
--- /dev/null
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -0,0 +1,796 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+
+ "{{modulePath}}/internal/config"
+ "github.com/spf13/cobra"
+)
+
+func newAuthCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "auth",
+ Short: "Manage authentication",
+ }
+
+ cmd.AddCommand(newAuthLoginCmd(flags))
+ cmd.AddCommand(newAuthStatusCmd(flags))
+ cmd.AddCommand(newAuthLogoutCmd(flags))
+
+ return cmd
+}
+
+// chromeProfile holds info about a discovered Chrome profile.
+type chromeProfile struct {
+ Dir string // directory name (e.g. "Default", "Profile 1")
+ DisplayName string // human-readable name from Preferences
+ CookieCount int // number of cookies matching the target domain
+}
+
+func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
+ var browserFlag bool
+ var profileFlag string
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Authenticate with the API",
+ Long: `Authenticate using your browser session.
+
+Use --chrome to read cookies from Chrome for {{.Auth.CookieDomain}}.
+Requires a cookie extraction tool (pycookiecheat, cookies, or cookie-scoop-cli).
+
+If you have multiple Chrome profiles, pycookiecheat and cookie-scoop-cli can
+auto-detect which profile is logged in. Use --profile to select a specific
+profile by name when the installed backend supports it.`,
+ Example: ` {{.Name}}-pp-cli auth login --chrome
+ {{.Name}}-pp-cli auth login --chrome --profile "Work"`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !browserFlag {
+ fmt.Fprintln(cmd.OutOrStdout(), "Use --chrome to authenticate from your Chrome session:")
+ fmt.Fprintf(cmd.OutOrStdout(), " {{.Name}}-pp-cli auth login --chrome\n")
+ return nil
+ }
+
+{{- if .Auth.EnvVars}}
+ // Check if already authenticated via env var
+ if v := os.Getenv("{{index .Auth.EnvVars 0}}"); v != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Already authenticated via %s env var.\n", "{{index .Auth.EnvVars 0}}")
+ return nil
+ }
+{{- end}}
+
+ w := cmd.OutOrStdout()
+ domain := "{{.Auth.CookieDomain}}"
+
+ // Step 1: Detect cookie extraction tool
+ tool, err := detectCookieTool()
+ if err != nil {
+ fmt.Fprintln(w, red("No cookie extraction tool found."))
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Install one of:")
+ fmt.Fprintln(w, " pip install pycookiecheat # Python (recommended)")
+ fmt.Fprintln(w, " brew install barnardb/cookies/cookies # Homebrew")
+ fmt.Fprintln(w, " cargo install cookie-scoop-cli # Rust")
+ return authErr(fmt.Errorf("no cookie tool found"))
+ }
+
+ if profileFlag != "" && !cookieToolSupportsProfiles(tool) {
+ return authErr(fmt.Errorf("%s does not support --profile; install pycookiecheat or cookie-scoop-cli", tool))
+ }
+
+ // Step 2: Resolve which Chrome profile to use when the backend can honor it
+ profileDir := ""
+ if cookieToolSupportsProfiles(tool) {
+ profileDir, err = resolveChromeProfile(w, cmd.InOrStdin(), domain, profileFlag)
+ if err != nil {
+ loginURL := "https://" + strings.TrimPrefix(domain, ".")
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, red("No Chrome profile has cookies for "+domain+"."))
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Log in to your account:")
+ fmt.Fprintf(w, "\n %s\n\n", loginURL)
+ fmt.Fprintln(w, "Then run this command again:")
+ fmt.Fprintf(w, "\n {{.Name}}-pp-cli auth login --chrome\n")
+ return authErr(fmt.Errorf("not logged in to %s", domain))
+ }
+ }
+
+ if profileDir != "" {
+ fmt.Fprintf(w, "Reading cookies from Chrome profile %q for %s...\n", profileDir, domain)
+ } else {
+ fmt.Fprintf(w, "Reading cookies for %s...\n", domain)
+ }
+
+ // Step 3: Extract cookies from the resolved profile
+ cookies, err := extractCookies(tool, domain, profileDir)
+ if err != nil {
+ return authErr(fmt.Errorf("extracting cookies: %w", err))
+ }
+
+ if cookies == "" {
+ return authErr(fmt.Errorf("cookie tool returned no cookies for %s", domain))
+ }
+
+{{- if eq .Auth.Type "composed"}}
+ // Step 4: Compose auth header from named cookies
+ cookieMap := parseCookieString(cookies)
+ requiredCookies := []string{ {{- range $i, $c := .Auth.Cookies}}{{if $i}}, {{end}}"{{$c}}"{{- end}} }
+
+ // Check if all required cookies were found on disk
+ allFound := true
+ for _, name := range requiredCookies {
+ if _, ok := cookieMap[name]; !ok {
+ allFound = false
+ break
+ }
+ }
+
+ // If required cookies are missing, they may be session cookies
+ // (in-memory only, not written to Chrome's cookie DB).
+ // Try reading them from a live Chrome session via browser tools.
+ if !allFound {
+ fmt.Fprintf(w, "Required cookies not in Chrome's cookie DB (likely session cookies).\n")
+ fmt.Fprintf(w, "Attempting to read from live Chrome session...\n")
+
+ loginURL := "https://" + strings.TrimPrefix(domain, ".")
+ liveCookies, liveErr := extractLiveCookies(domain)
+ if liveErr != nil {
+ fmt.Fprintf(w, "\n%s Could not read live session: %v\n", red("ERROR"), liveErr)
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Make sure Chrome is running with "+loginURL+" open and you are logged in.")
+ fmt.Fprintln(w, "Then run this command again:")
+ fmt.Fprintf(w, "\n {{.Name}}-pp-cli auth login --chrome\n")
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "If this keeps failing, install a browser tool:")
+ fmt.Fprintln(w, " pip install browser-use # or")
+ fmt.Fprintln(w, " npm install -g agent-browser")
+ return authErr(fmt.Errorf("session cookies not accessible for %s", domain))
+ }
+
+ // Merge live cookies into the map
+ for k, v := range parseCookieString(liveCookies) {
+ cookieMap[k] = v
+ }
+
+ // Re-check
+ for _, name := range requiredCookies {
+ if _, ok := cookieMap[name]; !ok {
+ fmt.Fprintf(w, "\n%s Cookie %q not found in Chrome (disk or live session).\n", red("ERROR"), name)
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Log in to your account:")
+ fmt.Fprintf(w, "\n %s\n\n", loginURL)
+ fmt.Fprintln(w, "Then run this command again:")
+ fmt.Fprintf(w, "\n {{.Name}}-pp-cli auth login --chrome\n")
+ return authErr(fmt.Errorf("cookie %q not found for %s", name, domain))
+ }
+ }
+ fmt.Fprintf(w, "%s Found session cookies via live Chrome.\n", green("OK"))
+ }
+
+ composed := "{{.Auth.Format}}"
+ for _, name := range requiredCookies {
+ composed = strings.ReplaceAll(composed, "{"+name+"}", cookieMap[name])
+ }
+
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ if err := cfg.SaveTokens("", "", composed, "", time.Time{}); err != nil {
+ return configErr(fmt.Errorf("saving auth: %w", err))
+ }
+
+ fmt.Fprintf(w, "%s Composed auth header from %d cookies for %s\n", green("OK"), len(requiredCookies), domain)
+ fmt.Fprintf(w, "Session saved to %s\n", cfg.Path)
+ return nil
+{{- else}}
+ // Step 4: Save to config
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ if err := cfg.SaveTokens("", "", cookies, "", time.Time{}); err != nil {
+ return configErr(fmt.Errorf("saving cookies: %w", err))
+ }
+
+ count := len(strings.Split(cookies, ";"))
+ fmt.Fprintf(w, "%s Found %d cookies for %s\n", green("OK"), count, domain)
+ fmt.Fprintf(w, "Session saved to %s\n", cfg.Path)
+ return nil
+{{- end}}
+ },
+ }
+
+ cmd.Flags().BoolVar(&browserFlag, "chrome", false, "Read cookies from Chrome")
+ cmd.Flags().StringVar(&profileFlag, "profile", "", "Chrome profile name (e.g. \"Work\", \"Personal\")")
+ return cmd
+}
+
+func cookieToolSupportsProfiles(tool string) bool {
+ switch tool {
+ case "pycookiecheat", "cookie-scoop":
+ return true
+ default:
+ return false
+ }
+}
+
+func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Show authentication status",
+ Example: " {{.Name}}-pp-cli auth status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ w := cmd.OutOrStdout()
+ header := cfg.AuthHeader()
+ if header == "" {
+{{- if .Auth.EnvVars}}
+ if v := os.Getenv("{{index .Auth.EnvVars 0}}"); v != "" {
+ fmt.Fprintln(w, green("Authenticated"))
+ fmt.Fprintf(w, " Source: %s env var\n", "{{index .Auth.EnvVars 0}}")
+ return nil
+ }
+{{- end}}
+ fmt.Fprintln(w, red("Not authenticated"))
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Log in from your browser session:")
+ fmt.Fprintf(w, " {{.Name}}-pp-cli auth login --chrome\n")
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+
+ fmt.Fprintln(w, green("Authenticated"))
+ fmt.Fprintf(w, " Source: %s\n", cfg.AuthSource)
+ fmt.Fprintf(w, " Domain: {{.Auth.CookieDomain}}\n")
+ fmt.Fprintf(w, " Config: %s\n", cfg.Path)
+ return nil
+ },
+ }
+}
+
+func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "logout",
+ Short: "Clear stored credentials",
+ Example: " {{.Name}}-pp-cli auth logout",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ if err := cfg.ClearTokens(); err != nil {
+ return configErr(fmt.Errorf("clearing tokens: %w", err))
+ }
+
+{{- if .Auth.EnvVars}}
+ if os.Getenv("{{index .Auth.EnvVars 0}}") != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", "{{index .Auth.EnvVars 0}}")
+ return nil
+ }
+{{- end}}
+ fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
+ return nil
+ },
+ }
+}
+
+// --- Chrome profile discovery ---
+
+// chromeDataDir returns the Chrome user data directory for the current OS.
+func chromeDataDir() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+
+ switch runtime.GOOS {
+ case "darwin":
+ return filepath.Join(home, "Library", "Application Support", "Google", "Chrome"), nil
+ case "linux":
+ return filepath.Join(home, ".config", "google-chrome"), nil
+ case "windows":
+ localAppData := os.Getenv("LOCALAPPDATA")
+ if localAppData == "" {
+ localAppData = filepath.Join(home, "AppData", "Local")
+ }
+ return filepath.Join(localAppData, "Google", "Chrome", "User Data"), nil
+ default:
+ return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
+ }
+}
+
+// discoverChromeProfiles finds Chrome profiles and counts cookies matching the domain.
+func discoverChromeProfiles(domain string) ([]chromeProfile, error) {
+ dataDir, err := chromeDataDir()
+ if err != nil {
+ return nil, err
+ }
+ if _, err := exec.LookPath("sqlite3"); err != nil {
+ return nil, fmt.Errorf("sqlite3 not found")
+ }
+
+ entries, err := os.ReadDir(dataDir)
+ if err != nil {
+ return nil, fmt.Errorf("cannot read Chrome data directory: %w", err)
+ }
+
+ // Match domain for SQLite query — host_key uses leading dot (e.g. ".notion.so")
+ domainPattern := "%" + strings.TrimPrefix(domain, ".") + "%"
+
+ var profiles []chromeProfile
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if name != "Default" && !strings.HasPrefix(name, "Profile ") {
+ continue
+ }
+
+ profilePath := filepath.Join(dataDir, name)
+ cookiesDB := filepath.Join(profilePath, "Cookies")
+ if _, err := os.Stat(cookiesDB); err != nil {
+ continue
+ }
+
+ displayName := readProfileDisplayName(filepath.Join(profilePath, "Preferences"))
+ if displayName == "" {
+ displayName = name
+ }
+
+ count := countCookiesForDomain(cookiesDB, domainPattern)
+
+ profiles = append(profiles, chromeProfile{
+ Dir: name,
+ DisplayName: displayName,
+ CookieCount: count,
+ })
+ }
+
+ // Sort: profiles with cookies first, then by directory name
+ sort.Slice(profiles, func(i, j int) bool {
+ if profiles[i].CookieCount != profiles[j].CookieCount {
+ return profiles[i].CookieCount > profiles[j].CookieCount
+ }
+ return profiles[i].Dir < profiles[j].Dir
+ })
+
+ return profiles, nil
+}
+
+// readProfileDisplayName extracts the human-readable name from Chrome's Preferences JSON.
+func readProfileDisplayName(prefsPath string) string {
+ data, err := os.ReadFile(prefsPath)
+ if err != nil {
+ return ""
+ }
+ var prefs struct {
+ Profile struct {
+ Name string `json:"name"`
+ } `json:"profile"`
+ }
+ if err := json.Unmarshal(data, &prefs); err != nil {
+ return ""
+ }
+ return prefs.Profile.Name
+}
+
+// countCookiesForDomain copies the Cookies DB (plus WAL/SHM) to temp and counts matching rows.
+// Uses sqlite3 when available; host_key is plaintext so no decryption is needed.
+func countCookiesForDomain(cookiesDB, domainPattern string) int {
+ tmpFile, err := os.CreateTemp("", "cookies-probe-*.db")
+ if err != nil {
+ return 0
+ }
+ tmpPath := tmpFile.Name()
+ tmpFile.Close()
+ defer os.Remove(tmpPath)
+ defer os.Remove(tmpPath + "-wal")
+ defer os.Remove(tmpPath + "-shm")
+
+ // Copy the database file plus WAL/SHM to avoid Chrome's WAL lock
+ // and to include uncommitted cookie writes that are still in the WAL.
+ if err := copyFileIfExists(cookiesDB, tmpPath); err != nil {
+ return 0
+ }
+ _ = copyFileIfExists(cookiesDB+"-wal", tmpPath+"-wal")
+ _ = copyFileIfExists(cookiesDB+"-shm", tmpPath+"-shm")
+
+ query := fmt.Sprintf("SELECT COUNT(*) FROM cookies WHERE host_key LIKE '%s'", domainPattern)
+ out, err := exec.Command("sqlite3", tmpPath, query).Output()
+ if err != nil {
+ return 0
+ }
+
+ count := 0
+ fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &count)
+ return count
+}
+
+// copyFileIfExists copies src to dst. Returns nil if src does not exist.
+func copyFileIfExists(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ defer in.Close()
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, in)
+ return err
+}
+
+// resolveChromeProfile determines which Chrome profile to read cookies from.
+// Priority: --profile flag > auto-detect (single match) > interactive prompt.
+func resolveChromeProfile(w io.Writer, r io.Reader, domain, profileFlag string) (string, error) {
+ if profileFlag != "" {
+ return resolveProfileByName(profileFlag)
+ }
+
+ profiles, err := discoverChromeProfiles(domain)
+ if err != nil {
+ // Profile probing is optional; cookie extraction can still succeed without it.
+ fmt.Fprintf(w, "Could not inspect Chrome profiles (%v); continuing without auto-detection.\n", err)
+ return "", nil
+ }
+
+ // Filter to profiles that have cookies for this domain
+ var withCookies []chromeProfile
+ for _, p := range profiles {
+ if p.CookieCount > 0 {
+ withCookies = append(withCookies, p)
+ }
+ }
+
+ switch len(withCookies) {
+ case 0:
+ return "", fmt.Errorf("no Chrome profile has cookies for %s", domain)
+ case 1:
+ fmt.Fprintf(w, "Auto-detected Chrome profile: %s (%s)\n", withCookies[0].DisplayName, withCookies[0].Dir)
+ return withCookies[0].Dir, nil
+ default:
+ // Multiple profiles have cookies — ask the user
+ fmt.Fprintf(w, "Multiple Chrome profiles have cookies for %s:\n", domain)
+ for i, p := range withCookies {
+ fmt.Fprintf(w, " %d. %s (%s, %d cookies)\n", i+1, p.DisplayName, p.Dir, p.CookieCount)
+ }
+ fmt.Fprintf(w, "Which profile? [1]: ")
+
+ scanner := bufio.NewScanner(r)
+ choice := 1
+ if scanner.Scan() {
+ text := strings.TrimSpace(scanner.Text())
+ if text != "" {
+ fmt.Sscanf(text, "%d", &choice)
+ }
+ }
+ if choice < 1 || choice > len(withCookies) {
+ choice = 1
+ }
+ selected := withCookies[choice-1]
+ fmt.Fprintf(w, "Using profile: %s (%s)\n", selected.DisplayName, selected.Dir)
+ return selected.Dir, nil
+ }
+}
+
+// resolveProfileByName finds a Chrome profile directory by its display name.
+func resolveProfileByName(name string) (string, error) {
+ dataDir, err := chromeDataDir()
+ if err != nil {
+ return "", err
+ }
+
+ entries, err := os.ReadDir(dataDir)
+ if err != nil {
+ return "", err
+ }
+
+ lowerName := strings.ToLower(name)
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ dirName := entry.Name()
+ if dirName != "Default" && !strings.HasPrefix(dirName, "Profile ") {
+ continue
+ }
+ displayName := readProfileDisplayName(filepath.Join(dataDir, dirName, "Preferences"))
+ if strings.ToLower(displayName) == lowerName || strings.ToLower(dirName) == lowerName {
+ return dirName, nil
+ }
+ }
+
+ return "", fmt.Errorf("Chrome profile %q not found", name)
+}
+
+// --- Cookie extraction tools ---
+
+// detectCookieTool checks for available cookie extraction tools in preference order.
+func detectCookieTool() (string, error) {
+ tools := []struct {
+ name string
+ check []string
+ }{
+ {"pycookiecheat", []string{"python3", "-c", "import pycookiecheat"}},
+ {"cookies", []string{"cookies", "--help"}},
+ {"cookie-scoop", []string{"cookie-scoop", "--help"}},
+ }
+
+ for _, tool := range tools {
+ if err := exec.Command(tool.check[0], tool.check[1:]...).Run(); err == nil {
+ return tool.name, nil
+ }
+ }
+ return "", fmt.Errorf("no cookie extraction tool found; install one: pip install pycookiecheat")
+}
+
+// extractCookies shells out to the detected tool and returns a Cookie header value string.
+// profileDir selects which Chrome profile to read from (e.g. "Default", "Profile 1").
+func extractCookies(tool, domain, profileDir string) (string, error) {
+ switch tool {
+ case "pycookiecheat":
+ return extractViaPycookiecheat(domain, profileDir)
+ case "cookies":
+ return extractViaCookiesCLI(domain)
+ case "cookie-scoop":
+ return extractViaCookieScoop(domain, profileDir)
+ default:
+ return "", fmt.Errorf("unknown cookie tool: %s", tool)
+ }
+}
+
+func extractViaPycookiecheat(domain, profileDir string) (string, error) {
+ cleanDomain := strings.TrimPrefix(domain, ".")
+ cookiePath := ""
+ if profileDir != "" && profileDir != "Default" {
+ dataDir, err := chromeDataDir()
+ if err == nil {
+ cookiePath = filepath.Join(dataDir, profileDir, "Cookies")
+ }
+ }
+
+ var script string
+ if cookiePath != "" {
+ // Use forward slashes so Python doesn't interpret backslashes as escapes on Windows
+ safePath := filepath.ToSlash(cookiePath)
+ script = fmt.Sprintf(
+ `import json; from pycookiecheat import chrome_cookies; print(json.dumps(chrome_cookies("https://%s", cookie_file="%s")))`,
+ cleanDomain, safePath,
+ )
+ } else {
+ script = fmt.Sprintf(
+ `import json; from pycookiecheat import chrome_cookies; print(json.dumps(chrome_cookies("https://%s")))`,
+ cleanDomain,
+ )
+ }
+
+ var out bytes.Buffer
+ cmd := exec.Command("python3", "-c", script)
+ cmd.Stdout = &out
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("pycookiecheat failed: %w", err)
+ }
+
+ var cookies map[string]string
+ if err := json.Unmarshal(out.Bytes(), &cookies); err != nil {
+ return "", fmt.Errorf("parsing pycookiecheat output: %w", err)
+ }
+
+ var parts []string
+ for name, value := range cookies {
+ parts = append(parts, name+"="+value)
+ }
+ return strings.Join(parts, "; "), nil
+}
+
+func extractViaCookiesCLI(domain string) (string, error) {
+ // barnardb/cookies doesn't support profile selection — reads Default
+ var out bytes.Buffer
+ cmd := exec.Command("cookies", "https://"+strings.TrimPrefix(domain, "."))
+ cmd.Stdout = &out
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("cookies cli failed: %w", err)
+ }
+
+ result := strings.TrimSpace(out.String())
+ result = strings.TrimPrefix(result, "Cookie: ")
+ return result, nil
+}
+
+// parseCookieString splits a "name1=value1; name2=value2" string into a map.
+func parseCookieString(cookies string) map[string]string {
+ m := make(map[string]string)
+ for _, pair := range strings.Split(cookies, "; ") {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+ idx := strings.IndexByte(pair, '=')
+ if idx < 0 {
+ continue
+ }
+ m[pair[:idx]] = pair[idx+1:]
+ }
+ return m
+}
+
+func extractViaCookieScoop(domain, profileDir string) (string, error) {
+ cleanDomain := strings.TrimPrefix(domain, ".")
+ args := []string{"--url", "https://" + cleanDomain, "--header"}
+ if profileDir != "" {
+ args = append(args, "--chrome-profile", profileDir)
+ }
+
+ var out bytes.Buffer
+ cmd := exec.Command("cookie-scoop", args...)
+ cmd.Stdout = &out
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("cookie-scoop failed: %w", err)
+ }
+
+ result := strings.TrimSpace(out.String())
+ result = strings.TrimPrefix(result, "Cookie: ")
+ return result, nil
+}
+
+// --- Live browser cookie extraction (session cookies fallback) ---
+
+// extractLiveCookies reads document.cookie from a live Chrome session.
+// Tries: agent-browser → browser-use → raw CDP, in that order.
+func extractLiveCookies(domain string) (string, error) {
+ targetURL := "https://" + strings.TrimPrefix(domain, ".")
+
+ // 1. Try agent-browser --auto-connect
+ if _, err := exec.LookPath("agent-browser"); err == nil {
+ cookies, err := extractViaAgentBrowser(targetURL)
+ if err == nil && cookies != "" {
+ return cookies, nil
+ }
+ }
+
+ // 2. Try browser-use --connect
+ if _, err := exec.LookPath("browser-use"); err == nil {
+ cookies, err := extractViaBrowserUse(targetURL)
+ if err == nil && cookies != "" {
+ return cookies, nil
+ }
+ }
+
+ // 3. Try raw CDP on common debug ports
+ for _, port := range []string{"9222", "9229"} {
+ cookies, err := extractViaCDP(targetURL, port)
+ if err == nil && cookies != "" {
+ return cookies, nil
+ }
+ }
+
+ return "", fmt.Errorf("no live browser connection available (tried agent-browser, browser-use, CDP)")
+}
+
+func extractViaAgentBrowser(targetURL string) (string, error) {
+ // Open the target URL (attaches to running Chrome)
+ openCmd := exec.Command("agent-browser", "--auto-connect", "open", targetURL)
+ openCmd.Stderr = os.Stderr
+ if err := openCmd.Run(); err != nil {
+ return "", fmt.Errorf("agent-browser open: %w", err)
+ }
+
+ // Read document.cookie via eval
+ var out bytes.Buffer
+ evalCmd := exec.Command("agent-browser", "eval", "document.cookie")
+ evalCmd.Stdout = &out
+ evalCmd.Stderr = os.Stderr
+ if err := evalCmd.Run(); err != nil {
+ closeCmd := exec.Command("agent-browser", "close")
+ _ = closeCmd.Run()
+ return "", fmt.Errorf("agent-browser eval: %w", err)
+ }
+
+ // Close the daemon
+ closeCmd := exec.Command("agent-browser", "close")
+ _ = closeCmd.Run()
+
+ return strings.TrimSpace(out.String()), nil
+}
+
+func extractViaBrowserUse(targetURL string) (string, error) {
+ // Try connecting to running Chrome
+ openCmd := exec.Command("browser-use", "--connect", "open", targetURL)
+ openCmd.Stderr = os.Stderr
+ if err := openCmd.Run(); err != nil {
+ return "", fmt.Errorf("browser-use open: %w", err)
+ }
+
+ var out bytes.Buffer
+ evalCmd := exec.Command("browser-use", "eval", "document.cookie")
+ evalCmd.Stdout = &out
+ evalCmd.Stderr = os.Stderr
+ if err := evalCmd.Run(); err != nil {
+ closeCmd := exec.Command("browser-use", "close")
+ _ = closeCmd.Run()
+ return "", fmt.Errorf("browser-use eval: %w", err)
+ }
+
+ closeCmd := exec.Command("browser-use", "close")
+ _ = closeCmd.Run()
+
+ result := strings.TrimSpace(out.String())
+ // browser-use eval prefixes with "result: "
+ result = strings.TrimPrefix(result, "result: ")
+ return result, nil
+}
+
+func extractViaCDP(targetURL, port string) (string, error) {
+ // Discover available tabs via CDP JSON endpoint
+ listURL := fmt.Sprintf("http://localhost:%s/json", port)
+ client := &http.Client{Timeout: 2 * time.Second}
+ resp, err := client.Get(listURL)
+ if err != nil {
+ return "", fmt.Errorf("CDP not available on port %s", port)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ // Find a tab matching the target domain
+ domain := strings.TrimPrefix(strings.TrimPrefix(targetURL, "https://"), "http://")
+ var tabs []struct {
+ URL string `json:"url"`
+ WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
+ }
+ if err := json.Unmarshal(body, &tabs); err != nil {
+ return "", fmt.Errorf("parsing CDP tabs: %w", err)
+ }
+
+ var wsURL string
+ for _, tab := range tabs {
+ if strings.Contains(tab.URL, domain) {
+ wsURL = tab.WebSocketDebuggerURL
+ break
+ }
+ }
+ if wsURL == "" {
+ return "", fmt.Errorf("no Chrome tab found for %s", domain)
+ }
+
+ // wsURL found but we need a WebSocket client to evaluate JS.
+ // For now, report that the tab exists — a future version can add
+ // a lightweight WebSocket eval. The agent-browser and browser-use
+ // paths handle the common case.
+ _ = wsURL
+ return "", fmt.Errorf("CDP tab found for %s but WebSocket eval not yet implemented (install agent-browser or browser-use)", domain)
+}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index b27c210b..c5ddef78 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -59,12 +59,17 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
path = replacePathParam(path, "{{.Name}}", args[{{$i}}])
{{- end}}
{{- end}}
+{{- range .Endpoint.Params}}
+{{- if and .PathParam (not .Positional)}}
+ path = replacePathParam(path, "{{.Name}}", fmt.Sprintf("%v", flag{{camel .Name}}))
+{{- end}}
+{{- end}}
{{- if or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD")}}
{{- if .Endpoint.Pagination}}
data, err := paginatedGet(c, path, map[string]string{
{{- range .Endpoint.Params}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
{{- end}}
{{- end}}
@@ -72,7 +77,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- else}}
params := map[string]string{}
{{- range .Endpoint.Params}}
-{{- if not .Positional}}
+{{- if and (not .Positional) (not .PathParam)}}
if flag{{camel .Name}} != {{zeroValForParam .Name .Type}} {
params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index fdbf3dd3..f4c8435e 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -126,6 +126,28 @@ func (c *Config) AuthHeader() string {
}
{{- end}}
return ""
+{{- else if eq .Auth.Type "cookie"}}
+ if c.AccessToken != "" {
+ c.AuthSource = "browser"
+ return c.AccessToken
+ }
+ {{- if gt (len .Auth.EnvVars) 0}}
+ if c.{{envVarField (index .Auth.EnvVars 0)}} != "" {
+ return c.{{envVarField (index .Auth.EnvVars 0)}}
+ }
+ {{- end}}
+ return ""
+{{- else if eq .Auth.Type "composed"}}
+ if c.AccessToken != "" {
+ c.AuthSource = "chrome-composed"
+ return c.AccessToken
+ }
+ {{- if gt (len .Auth.EnvVars) 0}}
+ if c.{{envVarField (index .Auth.EnvVars 0)}} != "" {
+ return c.{{envVarField (index .Auth.EnvVars 0)}}
+ }
+ {{- end}}
+ return ""
{{- else}}
return ""
{{- end}}
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index c66db7ba..ed1ace9b 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -8,6 +8,9 @@ import (
"net/http"
{{- if .Auth.EnvVars}}
"os"
+{{- end}}
+{{- if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
+ "os/exec"
{{- end}}
"strings"
"time"
@@ -36,6 +39,34 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
// Check auth
{{- if or (eq .Auth.Type "") (eq .Auth.Type "none")}}
report["auth"] = "not required"
+{{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
+ if cfg != nil {
+ header := cfg.AuthHeader()
+ if header == "" {
+ report["auth"] = "not configured"
+ report["auth_hint"] = "{{.Name}}-pp-cli auth login --chrome"
+ } else {
+ report["auth"] = "configured (browser session)"
+ report["auth_source"] = cfg.AuthSource
+ report["auth_domain"] = "{{.Auth.CookieDomain}}"
+ }
+ }
+ // Check cookie tool availability
+ cookieToolFound := false
+ for _, check := range [][]string{
+ {"python3", "-c", "import pycookiecheat"},
+ {"cookies", "--help"},
+ {"cookie-scoop", "--help"},
+ } {
+ if err := exec.Command(check[0], check[1:]...).Run(); err == nil {
+ cookieToolFound = true
+ report["cookie_tool"] = check[0]
+ break
+ }
+ }
+ if !cookieToolFound {
+ report["cookie_tool"] = "not found (install: pip install pycookiecheat)"
+ }
{{- else}}
if cfg != nil {
header := cfg.AuthHeader()
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 7cd009d3..3dd187c6 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -646,27 +646,21 @@ func printAutoTable(w io.Writer, items []map[string]any) error {
return nil
}
- // Collect headers from first item, prioritize common fields
- priority := []string{"id", "name", "username", "title", "status", "type", "email", "description"}
- headerSet := map[string]struct{}{}
- for k := range items[0] {
- headerSet[k] = struct{}{}
- }
-
- var headers []string
- for _, p := range priority {
- if _, ok := headerSet[p]; ok {
- headers = append(headers, p)
- delete(headerSet, p)
+ // Count scalar vs complex fields to decide format
+ scalarCount := 0
+ for _, v := range items[0] {
+ switch v.(type) {
+ case string, float64, bool, nil:
+ scalarCount++
}
}
- // Add remaining headers sorted
- var rest []string
- for k := range headerSet {
- rest = append(rest, k)
+
+ // Use sectional/card layout for complex items (many fields or nested data)
+ if len(items[0]) > 8 || scalarCount < len(items[0])-2 {
+ return printAutoCards(w, items)
}
- sort.Strings(rest)
- headers = append(headers, rest...)
+
+ headers := prioritizeHeaders(items[0])
// Limit to 6 columns max for readability
if len(headers) > 6 {
@@ -678,24 +672,7 @@ func printAutoTable(w io.Writer, items []map[string]any) error {
for _, item := range items {
row := make([]string, len(headers))
for i, h := range headers {
- v := item[h]
- switch val := v.(type) {
- case string:
- row[i] = truncate(val, 40)
- case float64:
- if val == float64(int64(val)) {
- row[i] = fmt.Sprintf("%d", int64(val))
- } else {
- row[i] = fmt.Sprintf("%g", val)
- }
- case bool:
- row[i] = fmt.Sprintf("%t", val)
- case nil:
- row[i] = ""
- default:
- b, _ := json.Marshal(val)
- row[i] = truncate(string(b), 40)
- }
+ row[i] = formatCellValue(item[h])
}
rows = append(rows, row)
}
@@ -713,3 +690,161 @@ func printAutoTable(w io.Writer, items []map[string]any) error {
}
return tw.Flush()
}
+
+// prioritizeHeaders orders scalar fields by importance for table display.
+func prioritizeHeaders(item map[string]any) []string {
+ return prioritizeFields(item, false)
+}
+
+// prioritizeAllHeaders orders all fields (including arrays) by importance for card display.
+func prioritizeAllHeaders(item map[string]any) []string {
+ return prioritizeFields(item, true)
+}
+
+// prioritizeFields orders fields by importance: identity → temporal → status → other.
+// When includeComplex is true, arrays and objects are included (for card layout).
+func prioritizeFields(item map[string]any, includeComplex bool) []string {
+ // Priority tiers — fields matching earlier tiers appear first.
+ // Each entry is a substring matched case-insensitively against the field name.
+ tiers := [][]string{
+ // Tier 0: Identity
+ {"id", "name", "title", "code", "key", "slug"},
+ // Tier 1: Temporal
+ {"date", "time", "created", "updated", "when", "at"},
+ // Tier 2: Status & outcome
+ {"status", "state", "result", "verdict", "phase"},
+ // Tier 3: Value & summary
+ {"summary", "description", "price", "amount", "total", "cost", "value", "points", "score"},
+ // Tier 4: Categorization
+ {"type", "kind", "category", "method", "store", "email", "phone", "url"},
+ }
+
+ type scored struct {
+ name string
+ tier int
+ index int // preserve order within same tier
+ }
+
+ var all []scored
+ idx := 0
+ for k, v := range item {
+ // Skip complex fields in table mode — they don't fit in cells.
+ // In card mode, arrays are flattened (e.g. Summary: "1 Pizza, 1 Pagliaccio").
+ if !includeComplex {
+ switch v.(type) {
+ case []any, map[string]any:
+ continue
+ }
+ }
+
+ tier := len(tiers) // default: lowest priority
+ lower := strings.ToLower(k)
+ for t, patterns := range tiers {
+ for _, p := range patterns {
+ if strings.Contains(lower, p) {
+ if t < tier {
+ tier = t
+ }
+ }
+ }
+ }
+ // Demote booleans — they're rarely the most useful columns
+ if _, ok := v.(bool); ok && tier == len(tiers) {
+ tier = len(tiers) + 1
+ }
+ all = append(all, scored{name: k, tier: tier, index: idx})
+ idx++
+ }
+
+ sort.Slice(all, func(i, j int) bool {
+ if all[i].tier != all[j].tier {
+ return all[i].tier < all[j].tier
+ }
+ return all[i].index < all[j].index
+ })
+
+ headers := make([]string, len(all))
+ for i, s := range all {
+ headers[i] = s.name
+ }
+ return headers
+}
+
+// printAutoCards renders items as labeled cards — one block per item.
+// Used for complex responses with many fields or nested data.
+func printAutoCards(w io.Writer, items []map[string]any) error {
+ headers := prioritizeAllHeaders(items[0])
+
+ // Find the longest header for alignment (from fields we'll actually show)
+ maxLen := 0
+ for _, h := range headers {
+ if len(h) > maxLen {
+ maxLen = len(h)
+ }
+ }
+
+ for i, item := range items {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+
+ // Card header: use first priority field as the card title
+ titleVal := formatCellValue(item[headers[0]])
+ if len(headers) > 1 {
+ secondVal := formatCellValue(item[headers[1]])
+ if secondVal != "" {
+ fmt.Fprintf(w, "%s %s — %s\n", bold(strings.ToUpper(headers[0])), titleVal, secondVal)
+ } else {
+ fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+ }
+ } else {
+ fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+ }
+
+ // Remaining fields indented — skip empty, zero, and false values
+ shown := 0
+ for _, h := range headers[2:] {
+ v := formatCellValue(item[h])
+ if v == "" || v == "false" || v == "0" || v == "[]" || v == "null" {
+ continue
+ }
+ fmt.Fprintf(w, " %-*s %s\n", maxLen, h+":", v)
+ shown++
+ if shown >= 12 {
+ break // cap at 12 visible fields per card
+ }
+ }
+ }
+ return nil
+}
+
+func formatCellValue(v any) string {
+ switch val := v.(type) {
+ case string:
+ return truncate(val, 60)
+ case float64:
+ if val == float64(int64(val)) {
+ return fmt.Sprintf("%d", int64(val))
+ }
+ return fmt.Sprintf("%g", val)
+ case bool:
+ return fmt.Sprintf("%t", val)
+ case nil:
+ return ""
+ case []any:
+ // Flatten simple arrays into comma-separated string
+ parts := make([]string, 0, len(val))
+ for _, item := range val {
+ if s, ok := item.(string); ok {
+ parts = append(parts, s)
+ } else {
+ b, _ := json.Marshal(item)
+ parts = append(parts, string(b))
+ }
+ }
+ return truncate(strings.Join(parts, ", "), 60)
+ default:
+ b, _ := json.Marshal(val)
+ return truncate(string(b), 60)
+ }
+}
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 53df8e95..52b5e4dd 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -66,6 +66,24 @@ Or set it via environment variable:
export {{index .Auth.EnvVars 0}}="your-token-here"
```
{{- end}}
+{{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
+
+### 2. Authenticate
+
+This CLI uses your browser session for authentication. Log in to {{.Auth.CookieDomain}} in Chrome, then:
+
+```bash
+{{.Name}}-pp-cli auth login --chrome
+```
+
+Requires a cookie extraction tool. Install one:
+
+```bash
+pip install pycookiecheat # Python (recommended)
+brew install barnardb/cookies/cookies # Homebrew
+```
+
+When your session expires, run `auth login --chrome` again.
{{- end}}
### {{if and (ne .Auth.Type "") (ne .Auth.Type "none")}}3{{else}}2{{end}}. Verify Setup
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 5d588827..d6bac32b 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -264,6 +264,32 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
auth.Header = "Authorization"
}
auth.In = strings.TrimSpace(scheme.In)
+ // Detect composed cookie auth via x-auth-type extension
+ if xType, ok := scheme.Extensions["x-auth-type"]; ok {
+ if typeStr, ok := xType.(string); ok && typeStr == "composed" {
+ auth.Type = "composed"
+ if xFmt, ok := scheme.Extensions["x-auth-format"]; ok {
+ if fmtStr, ok := xFmt.(string); ok {
+ auth.Format = fmtStr
+ }
+ }
+ if xDomain, ok := scheme.Extensions["x-auth-cookie-domain"]; ok {
+ if domainStr, ok := xDomain.(string); ok {
+ auth.CookieDomain = domainStr
+ }
+ }
+ if xCookies, ok := scheme.Extensions["x-auth-cookies"]; ok {
+ if cookieList, ok := xCookies.([]interface{}); ok {
+ for _, c := range cookieList {
+ if s, ok := c.(string); ok {
+ auth.Cookies = append(auth.Cookies, s)
+ }
+ }
+ }
+ }
+ break
+ }
+ }
// Detect bot token pattern from scheme name (e.g. "BotToken")
if strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
auth.Format = "Bot {bot_token}"
@@ -949,9 +975,86 @@ func mapParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []spec.P
}
params = append(params, param)
}
+
+ // Reclassify path params that are modifiers (not entity identifiers) as flags.
+ // This improves CLI UX: pagination/filter/date params become --flags with defaults
+ // instead of required positional args.
+ reclassifyPathParamModifiers(params)
+
return params
}
+// reclassifyPathParamModifiers converts path params that are modifiers (pagination,
+// filters, dates) from positional args to flags with sensible defaults. This improves
+// CLI UX — users type `order-list --page 2` instead of `order-list 2 10`.
+//
+// Classification priority (first match wins):
+// 1. Has an enum → flag (user picks from a set)
+// 2. Has a spec-declared default → flag with that default
+// 3. Known pagination name → flag with sensible default
+// 4. Date/time format → flag (defaults to empty, meaning "latest" or "today")
+// 5. Anything left → stays positional (likely an entity identifier)
+func reclassifyPathParamModifiers(params []spec.Param) {
+ paginationDefaults := map[string]int{
+ "page": 1, "pagenumber": 1, "page_number": 1,
+ "pagesize": 10, "page_size": 10, "per_page": 10,
+ "perpage": 10, "limit": 10, "count": 10,
+ "maxresults": 10, "max_results": 10,
+ "offset": 0, "skip": 0,
+ }
+
+ for i := range params {
+ p := ¶ms[i]
+ if !p.Positional {
+ continue // only reclassify path params
+ }
+ lowerName := strings.ToLower(p.Name)
+
+ // Decide whether this path param should be a flag instead of positional.
+ // Classification priority (first match wins):
+ reclassify := false
+
+ // 1. Has an enum → flag (user picks from a set)
+ if len(p.Enum) > 0 {
+ reclassify = true
+ if p.Default == nil {
+ p.Default = p.Enum[0]
+ }
+ }
+
+ // 2. Has a spec-declared default → flag
+ if !reclassify && p.Default != nil {
+ reclassify = true
+ }
+
+ // 3. Known pagination name → flag with default
+ if !reclassify {
+ if def, ok := paginationDefaults[lowerName]; ok {
+ reclassify = true
+ p.Default = def
+ }
+ }
+
+ // 4. Date/time format or name → flag
+ if !reclassify {
+ if p.Format == "date" || p.Format == "date-time" ||
+ strings.Contains(lowerName, "date") ||
+ strings.Contains(lowerName, "year") ||
+ strings.Contains(lowerName, "month") {
+ reclassify = true
+ }
+ }
+
+ if reclassify {
+ p.Positional = false
+ p.PathParam = true
+ // A path param is a URL segment — it can only be optional if a default
+ // value can fill its slot. No default → the user must provide a value.
+ p.Required = p.Default == nil
+ }
+ }
+}
+
func mergeParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []*openapi3.Parameter {
var merged []*openapi3.Parameter
index := map[string]int{}
@@ -1903,6 +2006,10 @@ func cleanSpecName(title string) string {
title = strings.ReplaceAll(title, "open api", " ")
+ // Strip apostrophes so brand names like "Domino's" become "dominos" not "domino-s"
+ title = strings.ReplaceAll(title, "'", "")
+ title = strings.ReplaceAll(title, "\u2019", "") // Unicode right single quotation mark
+
var normalized strings.Builder
lastSpace := true
for _, r := range title {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index ad4847c3..1d64bca3 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -8,6 +8,7 @@ import (
"github.com/mvanhorn/cli-printing-press/internal/generator"
"github.com/mvanhorn/cli-printing-press/internal/naming"
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -267,6 +268,106 @@ func TestOperationIDToName(t *testing.T) {
}
}
+func TestReclassifyPathParamModifiers(t *testing.T) {
+ tests := []struct {
+ name string
+ params []spec.Param
+ wantPositional []string // names that should stay positional
+ wantFlags []string // names that should become flags
+ }{
+ {
+ name: "pagination params become flags",
+ params: []spec.Param{
+ {Name: "page", Type: "int", Positional: true},
+ {Name: "pageSize", Type: "int", Positional: true},
+ },
+ wantPositional: nil,
+ wantFlags: []string{"page", "pageSize"},
+ },
+ {
+ name: "entity ID stays positional",
+ params: []spec.Param{
+ {Name: "storeId", Type: "int", Positional: true},
+ },
+ wantPositional: []string{"storeId"},
+ wantFlags: nil,
+ },
+ {
+ name: "mixed: storeId positional, page/pageSize flags",
+ params: []spec.Param{
+ {Name: "storeId", Type: "int", Positional: true},
+ {Name: "page", Type: "int", Positional: true},
+ {Name: "pageSize", Type: "int", Positional: true},
+ },
+ wantPositional: []string{"storeId"},
+ wantFlags: []string{"page", "pageSize"},
+ },
+ {
+ name: "enum param becomes flag",
+ params: []spec.Param{
+ {Name: "serviceType", Type: "string", Positional: true, Enum: []string{"PICK", "DEL"}},
+ },
+ wantPositional: nil,
+ wantFlags: []string{"serviceType"},
+ },
+ {
+ name: "date param becomes flag",
+ params: []spec.Param{
+ {Name: "storeId", Type: "int", Positional: true},
+ {Name: "date", Type: "string", Positional: true, Format: "date"},
+ },
+ wantPositional: []string{"storeId"},
+ wantFlags: []string{"date"},
+ },
+ {
+ name: "param with default becomes flag",
+ params: []spec.Param{
+ {Name: "version", Type: "string", Positional: true, Default: "v2"},
+ },
+ wantPositional: nil,
+ wantFlags: []string{"version"},
+ },
+ {
+ name: "non-positional params unchanged",
+ params: []spec.Param{
+ {Name: "lang", Type: "string", Positional: false},
+ },
+ wantPositional: nil,
+ wantFlags: nil, // already a flag, not reclassified
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reclassifyPathParamModifiers(tt.params)
+
+ var gotPositional, gotFlags []string
+ for _, p := range tt.params {
+ if p.Positional {
+ gotPositional = append(gotPositional, p.Name)
+ } else if p.PathParam {
+ gotFlags = append(gotFlags, p.Name)
+ }
+ }
+ assert.Equal(t, tt.wantPositional, gotPositional, "positional params")
+ assert.Equal(t, tt.wantFlags, gotFlags, "reclassified flag params")
+ })
+ }
+}
+
+func TestReclassifyPathParamDefaults(t *testing.T) {
+ params := []spec.Param{
+ {Name: "page", Type: "int", Positional: true},
+ {Name: "pageSize", Type: "int", Positional: true},
+ {Name: "serviceType", Type: "string", Positional: true, Enum: []string{"PICK", "DEL"}},
+ }
+ reclassifyPathParamModifiers(params)
+
+ assert.Equal(t, 1, params[0].Default, "page default should be 1")
+ assert.Equal(t, 10, params[1].Default, "pageSize default should be 10")
+ assert.Equal(t, "PICK", params[2].Default, "enum default should be first value")
+}
+
func TestCleanSpecName(t *testing.T) {
tests := []struct {
title string
@@ -277,6 +378,14 @@ func TestCleanSpecName(t *testing.T) {
{title: "Stytch API", want: "stytch"},
{title: "GitHub REST API", want: "github"},
{title: "", want: "api"},
+ // Apostrophes in brand names should be stripped, not hyphenated
+ {title: "Domino's Pizza API", want: "dominos-pizza"},
+ {title: "McDonald's API", want: "mcdonalds"},
+ {title: "Lowe's Home Improvement", want: "lowes-home-improvement"},
+ // Unicode right single quotation mark
+ {title: "Domino\u2019s Pizza API", want: "dominos-pizza"},
+ // Multiple apostrophes
+ {title: "Rock'n'Roll API", want: "rocknroll"},
}
for _, tt := range tests {
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
index 832109c5..98782fc5 100644
--- a/internal/pipeline/contracts_test.go
+++ b/internal/pipeline/contracts_test.go
@@ -72,8 +72,8 @@ func TestSkillSetupBlocksMatchWorkspaceContract(t *testing.T) {
assert.Contains(t, block, `PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"`)
assert.Contains(t, block, `PRESS_LIBRARY="$PRESS_HOME/library"`)
- // Must NOT reference repo-local binary or build
- assert.NotContains(t, block, `./printing-press`)
+ // May reference local build for repo-internal development,
+ // but must not hardcode go build or use ./printing-press as default
assert.NotContains(t, block, `go build`)
// Must NOT contain REPO_ROOT or cd to repo
assert.NotContains(t, block, `REPO_ROOT`)
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 51f7ec36..7b103b9c 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -25,7 +25,7 @@ type APISpec struct {
}
type AuthConfig struct {
- Type string `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, none
+ Type string `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, none
Header string `yaml:"header" json:"header"`
Format string `yaml:"format" json:"format"`
EnvVars []string `yaml:"env_vars" json:"env_vars"`
@@ -35,6 +35,8 @@ type AuthConfig struct {
AuthorizationURL string `yaml:"authorization_url,omitempty" json:"authorization_url,omitempty"`
TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"`
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
+ CookieDomain string `yaml:"cookie_domain,omitempty" json:"cookie_domain,omitempty"` // domain to read browser cookies from (e.g. ".notion.so")
+ Cookies []string `yaml:"cookies,omitempty" json:"cookies,omitempty"` // named cookies to extract for composed auth (e.g. ["customerId", "authToken"])
}
type ConfigSpec struct {
@@ -66,6 +68,7 @@ type Param struct {
Type string `yaml:"type" json:"type"`
Required bool `yaml:"required" json:"required"`
Positional bool `yaml:"positional" json:"positional"`
+ PathParam bool `yaml:"path_param,omitempty" json:"path_param,omitempty"` // true for path params rendered as flags (e.g., pagination)
Default any `yaml:"default" json:"default"`
Description string `yaml:"description" json:"description"`
Fields []Param `yaml:"fields" json:"fields"` // for nested objects
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index bfe226db..f4dcf9a7 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -295,6 +295,59 @@ resources:
assert.Equal(t, "bearer_token", s.Auth.Type)
})
+ t.Run("composed auth with cookies and format", func(t *testing.T) {
+ t.Parallel()
+ input := `name: pagliacciapi
+base_url: https://pag-api.azurewebsites.net/api
+auth:
+ type: composed
+ header: Authorization
+ format: "PagliacciAuth {customerId}|{authToken}"
+ cookie_domain: pagliacci.com
+ cookies:
+ - customerId
+ - authToken
+resources:
+ store:
+ description: Manage stores
+ endpoints:
+ list:
+ method: GET
+ path: /Store
+ description: List stores
+`
+ s, err := ParseBytes([]byte(input))
+ require.NoError(t, err)
+ assert.Equal(t, "composed", s.Auth.Type)
+ assert.Equal(t, "Authorization", s.Auth.Header)
+ assert.Equal(t, "PagliacciAuth {customerId}|{authToken}", s.Auth.Format)
+ assert.Equal(t, "pagliacci.com", s.Auth.CookieDomain)
+ assert.Equal(t, []string{"customerId", "authToken"}, s.Auth.Cookies)
+ })
+
+ t.Run("cookie auth without cookies field is nil", func(t *testing.T) {
+ t.Parallel()
+ input := `name: notionapi
+base_url: https://api.notion.so
+auth:
+ type: cookie
+ header: Cookie
+ cookie_domain: ".notion.so"
+resources:
+ pages:
+ description: Manage pages
+ endpoints:
+ list:
+ method: GET
+ path: /pages
+ description: List pages
+`
+ s, err := ParseBytes([]byte(input))
+ require.NoError(t, err)
+ assert.Equal(t, "cookie", s.Auth.Type)
+ assert.Nil(t, s.Auth.Cookies)
+ })
+
t.Run("invalid YAML still returns error", func(t *testing.T) {
t.Parallel()
input := `{{{not valid yaml at all`
diff --git a/internal/websniff/specgen.go b/internal/websniff/specgen.go
index 7bd44291..a65a0d21 100644
--- a/internal/websniff/specgen.go
+++ b/internal/websniff/specgen.go
@@ -317,18 +317,20 @@ func detectCapturedAuth(capture *AuthCapture, envPrefix string) spec.AuthConfig
}
case "cookie":
return spec.AuthConfig{
- Type: "cookie",
- Header: "Cookie",
- In: "cookie",
- Format: "informational only; no template support",
+ Type: "cookie",
+ Header: "Cookie",
+ In: "cookie",
+ CookieDomain: capture.BoundDomain,
+ EnvVars: envVarsOrNil(envPrefix, "COOKIES"),
}
}
case captureType == "cookie" && len(capture.Cookies) > 0:
return spec.AuthConfig{
- Type: "cookie",
- Header: "Cookie",
- In: "cookie",
- Format: "informational only; no template support",
+ Type: "cookie",
+ Header: "Cookie",
+ In: "cookie",
+ CookieDomain: capture.BoundDomain,
+ EnvVars: envVarsOrNil(envPrefix, "COOKIES"),
}
}
diff --git a/internal/websniff/specgen_test.go b/internal/websniff/specgen_test.go
index c3cccd08..c4be9bdd 100644
--- a/internal/websniff/specgen_test.go
+++ b/internal/websniff/specgen_test.go
@@ -149,7 +149,8 @@ func TestAnalyzeCapture_UsesCapturedCookieAuth(t *testing.T) {
assert.Equal(t, "cookie", apiSpec.Auth.Type)
assert.Equal(t, "Cookie", apiSpec.Auth.Header)
assert.Equal(t, "cookie", apiSpec.Auth.In)
- assert.Equal(t, "informational only; no template support", apiSpec.Auth.Format)
+ assert.Equal(t, "spotify.com", apiSpec.Auth.CookieDomain)
+ assert.Equal(t, []string{"SPOTIFY_COOKIES"}, apiSpec.Auth.EnvVars)
}
func TestDetectAuth_PrefersCapturedAuthOverHeaders(t *testing.T) {
diff --git a/skills/printing-press-catalog/SKILL.md b/skills/printing-press-catalog/SKILL.md
index aa2f37e2..952923ae 100644
--- a/skills/printing-press-catalog/SKILL.md
+++ b/skills/printing-press-catalog/SKILL.md
@@ -40,7 +40,16 @@ Before any other commands, run the setup contract to verify the printing-press b
<!-- PRESS_SETUP_CONTRACT_START -->
```bash
# min-binary-version: 0.2.0
-if ! command -v printing-press >/dev/null 2>&1; then
+
+# Derive scope first — needed for local build detection
+_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
+_scope_dir="$(cd "$_scope_dir" && pwd -P)"
+
+# Prefer local build when running from inside the printing-press repo.
+if [ -x "$_scope_dir/printing-press" ] && [ -d "$_scope_dir/cmd/printing-press" ]; then
+ export PATH="$_scope_dir:$PATH"
+ echo "Using local build: $_scope_dir/printing-press"
+elif ! command -v printing-press >/dev/null 2>&1; then
if [ -x "$HOME/go/bin/printing-press" ]; then
echo "printing-press found at ~/go/bin/printing-press but not on PATH."
echo "Add GOPATH/bin to your PATH: export PATH=\"\$HOME/go/bin:\$PATH\""
@@ -51,10 +60,6 @@ if ! command -v printing-press >/dev/null 2>&1; then
return 1 2>/dev/null || exit 1
fi
-# Derive scope: prefer git repo root, fall back to CWD
-_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
-_scope_dir="$(cd "$_scope_dir" && pwd -P)"
-
PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
if [ -z "$PRESS_BASE" ]; then
PRESS_BASE="workspace"
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index 557354df..eed8e36b 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -30,7 +30,16 @@ Before doing anything else:
<!-- PRESS_SETUP_CONTRACT_START -->
```bash
# min-binary-version: 0.5.0
-if ! command -v printing-press >/dev/null 2>&1; then
+
+# Derive scope first — needed for local build detection
+_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
+_scope_dir="$(cd "$_scope_dir" && pwd -P)"
+
+# Prefer local build when running from inside the printing-press repo.
+if [ -x "$_scope_dir/printing-press" ] && [ -d "$_scope_dir/cmd/printing-press" ]; then
+ export PATH="$_scope_dir:$PATH"
+ echo "Using local build: $_scope_dir/printing-press"
+elif ! command -v printing-press >/dev/null 2>&1; then
if [ -x "$HOME/go/bin/printing-press" ]; then
echo "printing-press found at ~/go/bin/printing-press but not on PATH."
echo "Add GOPATH/bin to your PATH: export PATH=\"\$HOME/go/bin:\$PATH\""
@@ -41,10 +50,6 @@ if ! command -v printing-press >/dev/null 2>&1; then
return 1 2>/dev/null || exit 1
fi
-# Derive scope: prefer git repo root, fall back to CWD
-_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
-_scope_dir="$(cd "$_scope_dir" && pwd -P)"
-
PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
if [ -z "$PRESS_BASE" ]; then
PRESS_BASE="workspace"
diff --git a/skills/printing-press-score/SKILL.md b/skills/printing-press-score/SKILL.md
index 23f9e46d..03bdf02f 100644
--- a/skills/printing-press-score/SKILL.md
+++ b/skills/printing-press-score/SKILL.md
@@ -36,7 +36,16 @@ Before any other commands, run the setup contract to verify the printing-press b
<!-- PRESS_SETUP_CONTRACT_START -->
```bash
# min-binary-version: 0.3.0
-if ! command -v printing-press >/dev/null 2>&1; then
+
+# Derive scope first — needed for local build detection
+_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
+_scope_dir="$(cd "$_scope_dir" && pwd -P)"
+
+# Prefer local build when running from inside the printing-press repo.
+if [ -x "$_scope_dir/printing-press" ] && [ -d "$_scope_dir/cmd/printing-press" ]; then
+ export PATH="$_scope_dir:$PATH"
+ echo "Using local build: $_scope_dir/printing-press"
+elif ! command -v printing-press >/dev/null 2>&1; then
if [ -x "$HOME/go/bin/printing-press" ]; then
echo "printing-press found at ~/go/bin/printing-press but not on PATH."
echo "Add GOPATH/bin to your PATH: export PATH=\"\$HOME/go/bin:\$PATH\""
@@ -47,10 +56,6 @@ if ! command -v printing-press >/dev/null 2>&1; then
return 1 2>/dev/null || exit 1
fi
-# Derive scope: prefer git repo root, fall back to CWD
-_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
-_scope_dir="$(cd "$_scope_dir" && pwd -P)"
-
PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
if [ -z "$PRESS_BASE" ]; then
PRESS_BASE="workspace"
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 01fcb7c1..0f8e6660 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -194,7 +194,18 @@ Before doing anything else:
<!-- PRESS_SETUP_CONTRACT_START -->
```bash
# min-binary-version: 0.3.0
-if ! command -v printing-press >/dev/null 2>&1; then
+
+# Derive scope first — needed for local build detection
+_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
+_scope_dir="$(cd "$_scope_dir" && pwd -P)"
+
+# Prefer local build when running from inside the printing-press repo.
+# The lefthook build hook keeps ./printing-press current after every commit/pull,
+# so it's always newer than the go-install version.
+if [ -x "$_scope_dir/printing-press" ] && [ -d "$_scope_dir/cmd/printing-press" ]; then
+ export PATH="$_scope_dir:$PATH"
+ echo "Using local build: $_scope_dir/printing-press"
+elif ! command -v printing-press >/dev/null 2>&1; then
if [ -x "$HOME/go/bin/printing-press" ]; then
export PATH="$HOME/go/bin:$PATH"
echo "Added ~/go/bin to PATH"
@@ -209,10 +220,6 @@ if ! command -v printing-press >/dev/null 2>&1; then
fi
fi
-# Derive scope: prefer git repo root, fall back to CWD
-_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
-_scope_dir="$(cd "$_scope_dir" && pwd -P)"
-
PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
if [ -z "$PRESS_BASE" ]; then
PRESS_BASE="workspace"
@@ -575,13 +582,13 @@ After Phase 1 research completes, analyze findings to proactively assess what au
If the user provides a key, set it in `AUTH_CONTEXT` so the API Key Gate (Phase 0.5) does not re-ask.
**For browser session auth:** Present via `AskUserQuestion`:
-> "`<API>` has authenticated endpoints ([list features]). Are you logged in to `<site>` in your browser? The sniff will discover more endpoints if you are."
+> "`<API>` has authenticated endpoints ([list features]). Are you logged in to `<site>` in your browser? If so, the generated CLI will support `auth login --chrome` — you'll be able to authenticate just by being logged into the site in Chrome. No API key needed."
>
-> 1. **Yes, I'm logged in** — I'll use your session during sniff
+> 1. **Yes, I'm logged in** — I'll use your session during sniff and enable browser auth in the CLI
> 2. **No, but I can log in** — I'll help you log in before sniffing
> 3. **No, skip authenticated endpoints** — sniff only public endpoints
-Set `AUTH_SESSION_AVAILABLE=true` if the user selects option 1 or 2. The Sniff Gate (Phase 1.7) will use this flag.
+Set `AUTH_SESSION_AVAILABLE=true` if the user selects option 1 or 2. The Sniff Gate (Phase 1.7) will use this flag. After traffic capture, Step 2d in [references/sniff-capture.md](references/sniff-capture.md) validates that cookie replay works before enabling browser auth in the generated CLI.
**For dual auth:** Ask about both in sequence — API key first (simple env var check), then browser session.
diff --git a/skills/printing-press/references/sniff-capture.md b/skills/printing-press/references/sniff-capture.md
index 66b19850..acc7241f 100644
--- a/skills/printing-press/references/sniff-capture.md
+++ b/skills/printing-press/references/sniff-capture.md
@@ -3,6 +3,14 @@
> **When to read:** This file is referenced by Phase 1.7 of the printing-press skill.
> Read it when the user approves sniff (browser-use or agent-browser capture of live API traffic).
+### Cardinal Rules
+
+1. **ALWAYS use browser-use for capture.** Do NOT substitute curl probing, JS bundle grepping, or agent-browser auto-connect for a proper browser-use interactive sniff. Agent-browser is for session transfer only (grabbing cookies from a running Chrome). The capture — browsing pages, collecting URLs, intercepting requests — MUST use browser-use.
+
+2. **Do NOT skip auth discovery when the session expires.** *(Only applies when `AUTH_SESSION_AVAILABLE=true` — the user confirmed they're logged in.)* If a Chrome profile loads but the session has expired (login page visible instead of account page), offer headed login as a fallback. Never proceed without auth just because the profile session was stale. For anonymous sniffs (no auth context), this rule does not apply.
+
+3. **Use click-based SPA navigation after installing interceptors.** `browser-use open` triggers a full page reload which resets the JS context and destroys fetch/XHR interceptors. After installing interceptors, navigate by clicking links (`browser-use eval "document.querySelector('a[href*=account]').click()"` or `browser-use click`). Only use `browser-use open` for the first page load or when you need to re-install interceptors.
+
### If user approves sniff
#### Sniff Pacing
@@ -170,15 +178,14 @@ if pgrep -x "Google Chrome" >/dev/null 2>&1; then
fi
```
-**When Chrome IS running**, prefer agent-browser (attaches to live browser without closing it):
+**When Chrome IS running**, use agent-browser to grab cookies, then ask the user to quit Chrome so browser-use can load the profile for capture:
Present via `AskUserQuestion`:
-> "Chrome is running. I can attach to it and grab your session."
+> "Chrome is running. I'll grab your cookies, then need you to quit Chrome so I can sniff with full page access."
>
-> 1. **Grab session from your Chrome** (Recommended) — "Saves your cookies, then sniffs in a separate headless browser. Chrome stays untouched."
-> 2. **Sniff in your Chrome directly** — "Stays connected to your real Chrome. You'll see pages changing during the sniff (~60-90 seconds). Simplest approach — no daemon juggling."
-> 3. **Log in within a new browser window** — "I'll open a visible browser. You log in, then I sniff. ~1 minute."
-> 4. **I'll export a HAR file** — "You browse the site in DevTools, export the HAR."
+> 1. **Grab session, then quit Chrome** (Recommended) — "I save your cookies via agent-browser, you quit Chrome, then I sniff with browser-use using your profile. Full DOM access."
+> 2. **Log in within a new browser window** — "I'll open a visible browser. You log in, then I sniff. ~1 minute."
+> 3. **I'll export a HAR file** — "You browse the site in DevTools, export the HAR."
For option 1 (save-then-restore):
@@ -196,19 +203,10 @@ agent-browser --state "$DISCOVERY_DIR/session-state.json" open <url>
```
If auto-connect fails (no debug port), explain: "Chrome doesn't have remote debugging enabled. Quit Chrome and relaunch with `--remote-debugging-port=9222`, or pick option 2."
-For option 2 (stay in auto-connect mode):
+For option 1 after cookies are saved and Chrome is quit:
```bash
-# Stay connected to the user's real Chrome — all cookies are already present
-agent-browser --auto-connect open <url>
-agent-browser network har start
-# ... browse pages (user will see their Chrome tabs changing) ...
-agent-browser network har stop <path>
-# No close/restart needed — daemon stays connected to real Chrome
-```
-
-For option 1 with browser-use (if agent-browser not available):
-```bash
-browser-use open <url> --connect
+# Start browser-use with the Chrome profile (has all saved cookies/logins)
+browser-use --profile "Default" open <url>
```
**When Chrome is NOT running**, prefer browser-use (loads real Chrome profile with all cookies):
@@ -228,9 +226,16 @@ If browser-use is not available, fall back to agent-browser headed login.
If Chrome profile lock error occurs (Chrome is actually running): "Chrome's profile is locked. Quit Chrome first, or switch to option 2."
-**When both tools are available**, recommend the situationally better one:
-- Chrome running: prefer agent-browser `--auto-connect`
-- Chrome not running: prefer browser-use `--profile "Default"`
+**Session transfer vs capture are separate concerns.** Use agent-browser for session transfer only (grabbing cookies from a running Chrome). Always use browser-use for the actual capture (Steps 2a.*) because it has full DOM access via eval, scroll, click, and snapshot. Agent-browser's auto-connect mode cannot access the DOM or run eval — it can navigate and record HAR but cannot interact with pages.
+
+Recommended flow when Chrome IS running:
+1. Use agent-browser `--auto-connect state save` to grab cookies
+2. Close agent-browser daemon
+3. Ask user to quit Chrome
+4. Start browser-use `--profile "Default"` for capture (loads the same cookies via the Chrome profile)
+
+When Chrome is NOT running:
+- Use browser-use `--profile "Default"` directly for both session and capture
**For headed login (option 2 with either tool):**
```bash
@@ -264,10 +269,27 @@ If no target-domain cookies are found, present via `AskUserQuestion`:
> "Session transfer failed — no `<target-domain>` cookies found in the browser. The sniff would run unauthenticated."
>
-> 1. **Try auto-connect mode instead** — "Stay connected to your real Chrome where you're already logged in"
-> 2. **Log in manually** — "I'll open a headed browser. You log in, then I sniff."
-> 3. **Continue without auth** — "Sniff only public endpoints"
-> 4. **Provide HAR manually** — "Export a HAR yourself from browser DevTools"
+> 1. **Log in manually** — "I'll open a headed browser. You log in, then I sniff."
+> 2. **Continue without auth** — "Sniff only public endpoints"
+> 3. **Provide HAR manually** — "Export a HAR yourself from browser DevTools"
+
+**After loading a Chrome profile**, also verify the session is actually active on the target site. Cookies may exist but be expired:
+
+```bash
+# Navigate to the site and check for login indicators
+browser-use eval "var login=document.querySelector('a[href*=login],a[href*=signin],[class*=sign-in],[class*=login-btn]');
+var account=document.querySelector('a[href*=account],a[href*=profile],[class*=logged-in],[class*=user-menu]');
+login && !account ? 'SESSION_EXPIRED' : account ? 'SESSION_ACTIVE' : 'UNKNOWN'"
+```
+
+If the result is `SESSION_EXPIRED` (login link visible, no account link), the profile cookies have expired. Present via `AskUserQuestion`:
+
+> "Your browser session for `<site>` has expired (login page visible). I need a fresh login to discover authenticated endpoints."
+>
+> 1. **Open headed browser to log in** (Recommended) — "I'll open a visible browser. You log in, then I continue the sniff."
+> 2. **Continue without auth** — "Sniff only public endpoints"
+
+Do NOT silently proceed without auth when the session has expired. The authenticated surface is often the most valuable part of the API (order history, rewards, saved data).
If cookies are verified, proceed to Steps 2a/2b capture flow with the authenticated session loaded. The session state file is stored at `$DISCOVERY_DIR/session-state.json`.
@@ -306,8 +328,101 @@ Example for "Check today's scores" (ESPN):
Each step triggers API calls that page loads alone would miss. After the primary flow, add 1-2 secondary flows from the research brief's other top workflows (e.g., "Check rewards," "Track an order").
+**Step 2a.1.5: Authenticated flow (when `AUTH_SESSION_AVAILABLE=true`)**
+
+When the user confirmed a logged-in session (AUTH_SESSION_AVAILABLE=true from Phase 1.6), add authenticated page visits AFTER the primary flow. The primary flow discovers the public API surface; the authenticated flow discovers what's behind the login wall.
+
+1. **Record the public endpoint set.** Before visiting auth pages, note which endpoints have been discovered so far. These are the "public set" — reachable without session cookies.
+
+2. **Visit account/profile pages.** Navigate to common authenticated URLs. Try these patterns in order, stopping at the first that loads a real page (not a redirect to login):
+ - `/account`, `/my-account`, `/profile`, `/settings`
+ - `/orders`, `/order-history`, `/my-orders`
+ - `/rewards`, `/loyalty`, `/my-deals-and-rewards`
+ - `/addresses`, `/saved-addresses`, `/payment-methods`
+
+ Also derive page URLs from the research brief's top workflows. If the brief mentions "order history" or "rewards" or "saved addresses," visit the corresponding pages even if they don't match the common patterns above.
+
+3. **Interact with auth pages.** Apply the SPA interaction rule below — click tabs, expand sections, trigger lazy loads. Auth pages often have sub-sections (e.g., "Recent Orders" tab, "Rewards History" tab) that fire separate API calls.
+
+4. **Classify endpoints.** After visiting auth pages, compare the new endpoints against the public set:
+ - Endpoints that appear ONLY during auth page visits → classify as **auth-required**
+ - Endpoints that appear in both public and auth visits → classify as **public**
+ - Record the classification in the discovery report's Endpoints table (add an "Auth" column)
+
+5. **Discover the auth header pattern.** Many SPAs don't send cookies directly — they read tokens from cookies/localStorage and construct an `Authorization` header. Install an XHR header interceptor and trigger a client-side navigation (click a link — do NOT use `browser-use open`) to capture the actual request headers:
+
+ ```bash
+ # Install header interceptor
+ browser-use eval "window.__authHeaders={};
+ const _s=XMLHttpRequest.prototype.setRequestHeader;
+ XMLHttpRequest.prototype.setRequestHeader=function(k,v){
+ if(k.toLowerCase()==='authorization')window.__authHeaders[k]=v.substring(0,100);
+ _s.apply(this,arguments)};'OK'"
+
+ # Navigate via SPA click (preserves interceptor)
+ browser-use eval "document.querySelector('a[href*=account],a[href*=orders],a[href*=rewards]').click()"
+ sleep 3
+
+ # Collect captured auth headers
+ browser-use eval "JSON.stringify(window.__authHeaders)"
+ ```
+
+ **If an Authorization header is found:**
+ - Record the scheme (e.g., `Bearer`, `PagliacciAuth`, `Token`, custom)
+ - **Trace values back to cookies.** Read `document.cookie` and match literal values from the captured header against cookie values:
+ ```bash
+ browser-use eval "document.cookie"
+ ```
+ For each cookie `name=value`, check if `value` appears as a substring in the Authorization header. When a match is found, record the cookie name and which part of the header it corresponds to.
+ - **Construct the format string.** Replace each literal cookie value in the header with `{cookieName}`:
+ - Example: header `PagliacciAuth 2432962|FD44DA6A-...`, cookies `customerId=2432962; authToken=FD44DA6A-...`
+ - Format string: `PagliacciAuth {customerId}|{authToken}`
+ - **Write composed auth into the spec.** When building the spec YAML, include:
+ ```yaml
+ auth:
+ type: composed
+ header: Authorization
+ format: "<format string with {cookieName} placeholders>"
+ cookie_domain: <site domain>
+ cookies:
+ - <cookie1Name>
+ - <cookie2Name>
+ ```
+ This tells the generator to emit `auth login --chrome` that reads those specific cookies and composes the header. The user never sees the format — the CLI handles it.
+ - Also check localStorage for token sources:
+ ```bash
+ browser-use eval "JSON.stringify(Object.keys(localStorage).filter(k=>k.match(/token|auth|session/i)))"
+ ```
+ - Use the composed header in Step 2d validation (not cookie replay — construct the actual header from extracted cookies)
+ - Record the auth scheme in the discovery report
+
+ **If cookie matching fails** (header values don't match any cookie values — possibly URL-encoded or hashed), fall back to recording the auth scheme without composed config. The printed CLI will use generic token auth. Report: "Auth header discovered but could not trace values to cookies."
+
+ **If no Authorization header found** but auth endpoints returned data (from step 4), the API likely uses cookie-based auth directly. Write `auth.type: cookie` into the spec and proceed to Step 2d with cookie replay.
+
+ **If the interceptor captured nothing** (page didn't fire API calls), try clicking a different link or scrolling the page. If still nothing after 2 attempts, proceed to Step 2d with cookie replay as a fallback.
+
+6. **Trigger auth validation.** If any auth-required endpoints were found, Step 2d (Cookie/token auth validation) MUST run. Use whichever auth method was discovered in step 5:
+ - If an Authorization header was found → replay with that header
+ - If no header found → try cookie replay
+ This is what propagates `Auth.Type` and auth config into the spec.
+
+7. **If auth pages redirect to login.** The session may have expired between the time the user confirmed login and the sniff reaches this step. Report: "Auth pages redirected to login — session may have expired. Auth-only endpoints not discovered." Do NOT fail the sniff — the public endpoints are still valid. Proceed to Step 2a.2 with the public set only.
+
**SPA interaction rule:** On each page/state, take a snapshot first. Look for interactive elements (buttons, forms, dropdowns, tabs). Click through them. SPAs fire API calls on interaction, not on page load. If you load a page and see no XHR activity, that means you need to interact with the page, not that there is nothing to find.
+**SPA navigation rule:** After installing fetch/XHR interceptors, do NOT use `browser-use open` to navigate between pages — it triggers a full page reload which destroys the interceptors. Instead, navigate by clicking links:
+```bash
+# Good: SPA navigation preserves interceptors
+browser-use eval "document.querySelector('a[href*=\"/orders\"]').click()"
+# or
+browser-use click "Orders"
+
+# Bad: full reload destroys interceptors
+browser-use open "https://site.com/orders"
+```
+Only use `browser-use open` for the initial page load (before interceptors exist) or when you intentionally want to re-install interceptors on a fresh page.
+
**Step 2a.2: Collect API URLs**
Open a headless browser session, then visit each page and collect API URLs using the Performance API:
@@ -338,6 +453,97 @@ Replace `<api-domain-1>`, `<api-domain-2>` etc. with the API domains discovered
**Why Performance API:** It is built into every browser, captures all resource loads (including those that fire before any JS interceptor could be injected), survives within a page lifecycle, and returns simple URL strings. Do NOT use `fetch`/`XMLHttpRequest` monkey-patching — it breaks on page navigation.
+**Step 2a.2.5: GraphQL BFF detection**
+
+After collecting URLs, check whether the site uses a GraphQL BFF pattern. This is common in modern SPAs (Domino's, Notion, Shopify storefronts) where all API traffic goes through a single `/graphql` or `/api/graphql` endpoint.
+
+**Detection signal:** If >50% of captured XHR/fetch POST URLs resolve to the same path (e.g., `/api/web-bff/graphql`, `/graphql`, `/api/graphql`), classify as a GraphQL BFF.
+
+**If GraphQL BFF detected:**
+
+1. **Extract operation names from POST bodies.** The URL alone tells you nothing — all calls go to the same endpoint. The value is in the request bodies.
+
+ For agent-browser:
+ ```bash
+ # List all XHR requests
+ agent-browser network requests --type xhr --json
+ # For each POST to the GraphQL endpoint, get the full request including body:
+ agent-browser network request <request-id> --json
+ # Parse: look for operationName and query fields in the request body
+ ```
+
+ For browser-use: inject a fetch interceptor BEFORE browsing auth/interaction pages. This captures POST bodies that the Performance API misses:
+ ```bash
+ browser-use eval "window.__gqlOps=[];const _f=window.fetch;window.fetch=async function(){const r=await _f.apply(this,arguments);try{if(arguments[0]&&arguments[0].toString().includes('graphql')&&arguments[1]&&arguments[1].body){const b=JSON.parse(arguments[1].body);if(b.operationName)window.__gqlOps.push({op:b.operationName,vars:Object.keys(b.variables||{})})}}catch(e){}return r}"
+ ```
+ After browsing, collect:
+ ```bash
+ browser-use eval "JSON.stringify(window.__gqlOps)"
+ ```
+
+2. **Record operations.** For each unique `operationName`, record:
+ - Operation name (e.g., `GetStoreMenu`, `AddToCart`, `GetOrderHistory`)
+ - Type: query (read) or mutation (write) — infer from the `query` field prefix or from naming convention (`Get*` = query, `Add*`/`Create*`/`Update*`/`Delete*` = mutation)
+ - Variable keys (e.g., `storeId`, `productCode`) — these become CLI flags
+ - Domain group — group by prefix (e.g., `Store*`, `Menu*`, `Order*`, `Account*`)
+
+3. **Write to discovery report.** Replace (or supplement) the "Endpoints Discovered" table with a "GraphQL Operations" table:
+ ```
+ | Operation | Type | Variables | Domain |
+ |-----------|------|-----------|--------|
+ | GetStoreMenu | query | storeId, lang | Store |
+ | AddToCart | mutation | productCode, qty | Order |
+ ```
+
+4. **Feed into spec building.** When building the OpenAPI spec from discovered operations, each GraphQL operation becomes a spec path: `POST /graphql#OperationName`. The operation name goes in `operationId`. Variables become request body properties. This is compatible with the existing generator — it sees each operation as a distinct POST endpoint.
+
+**If NOT a GraphQL BFF:** Skip this step. The existing URL-based discovery flow handles REST APIs.
+
+**Step 2a.2.7: JS bundle endpoint extraction (supplementary)**
+
+SPA frameworks (Angular, React, Vue, Next.js) compile all API endpoint paths into their main JS bundle. Extracting these paths supplements the sniff with endpoints that no user flow visits (admin features, migration tools, rarely-used settings).
+
+**When to run:** After completing the interactive sniff (Steps 2a.1–2a.2.5). This is supplementary — the sniff is primary because it provides response shapes, auth patterns, and parameter types. Bundle extraction only gives endpoint paths.
+
+**Skip when:** The site is server-rendered HTML without JS bundles, or the sniff already discovered 20+ endpoints and the API surface appears complete.
+
+1. **Find the main bundle:**
+ ```bash
+ browser-use eval "Array.from(document.querySelectorAll('script[src]')).map(s=>s.src).filter(s=>s.includes(location.hostname)&&(s.includes('main')||s.includes('app'))).join('\\n')"
+ ```
+
+2. **Download and extract API paths:**
+ ```bash
+ curl -s "<bundle-url>" | python3 -c "
+ import sys, re
+ content = sys.stdin.read()
+
+ # Find the API base URL config (common patterns)
+ base_match = re.search(r'(apiUrl|baseUrl|API_URL)[^\"]*\"(https?://[^\"]+)\"', content)
+ if base_match:
+ print(f'API base: {base_match.group(2)}')
+
+ # Extract capitalized path segments (API routes)
+ paths = re.findall(r'\"(/[A-Z][a-zA-Z]+(?:/[A-Z]?[a-zA-Z]*)*)\"|\"(/[a-z]+/[a-zA-Z]+)\"', content)
+ unique = sorted(set(p[0] or p[1] for p in paths if p[0] or p[1]))
+ for p in unique:
+ print(f' {p}')
+
+ # Extract HTTP method calls
+ calls = re.findall(r'\.(get|post|put|delete|patch)\([^)]*\"(/[A-Za-z][A-Za-z0-9/\${}]+)', content)
+ for method, path in sorted(set(calls)):
+ print(f' {method.upper()} {path}')
+ "
+ ```
+
+3. **Merge with sniff results.** Append bundle-discovered endpoints to `$SNIFF_URLS`. Mark their provenance:
+ ```bash
+ # Append bundle-only endpoints (not already in sniff-urls.txt)
+ # In the discovery report, mark these as "discovered: bundle"
+ ```
+
+4. **Record API config.** If the bundle reveals useful config (API version headers, auth token construction, rate limit hints), note them in the discovery report's Sniff Configuration section.
+
**Step 2a.3: Deduplicate and normalize**
After collecting from all pages:
@@ -420,6 +626,47 @@ After completing the primary user flow capture (browser-use or agent-browser), c
If the thin-results check triggers a re-sniff that discovers additional endpoints, merge the new captures with the originals before proceeding to Step 3.
+#### Step 2d: Cookie auth validation (authenticated sniff only)
+
+**Skip this step if:** The sniff was anonymous (no session transfer in Step 1d), or the API uses API key / Bearer token auth rather than cookie-based session auth.
+
+**Purpose:** Before promising `auth login --chrome` in the generated CLI, validate that browser cookies actually produce authenticated responses when replayed outside the browser context. Some APIs use CSRF tokens, SameSite cookie policies, or other mechanisms that prevent cookie-only replay.
+
+**Validation procedure:**
+
+1. **Select a test endpoint.** Pick one endpoint from the capture that returned HTTP 200 and appears to require authentication (e.g., a user-specific resource like `/api/me`, `/account`, or `/orders`).
+
+2. **Replay with cookies.** Using `curl` or the capture tool, replay the request with the captured cookies attached:
+ ```bash
+ curl -s -o /dev/null -w "%{http_code}" \
+ -H "Cookie: <captured-cookie-string>" \
+ "https://<api-domain>/<test-endpoint>"
+ ```
+ Expected: HTTP 200 (or the same status as during capture).
+
+3. **Replay without cookies.** Replay the same request with no cookies:
+ ```bash
+ curl -s -o /dev/null -w "%{http_code}" \
+ "https://<api-domain>/<test-endpoint>"
+ ```
+ Expected: HTTP 401, 403, or a redirect to a login page.
+
+4. **Evaluate results:**
+
+ | With cookies | Without cookies | Verdict |
+ |-------------|----------------|---------|
+ | 200 | 401/403/302 | **Pass** — cookie auth works. Set `Auth.Type = "cookie"` and `CookieDomain` in the spec. The generated CLI will include `auth login --chrome`. |
+ | 200 | 200 (same content) | **Not required** — cookies aren't needed for this endpoint. Check other endpoints; if none require auth, set `Auth.Type = "none"`. |
+ | 401/403 | 401/403 | **Fail** — cookies don't replay (likely CSRF, SameSite, or IP binding). Warn the user and do not offer browser auth. |
+ | Other | Any | **Inconclusive** — try a different test endpoint. If all attempts are inconclusive after 3 endpoints, treat as Fail. |
+
+5. **On Pass:** Proceed to Step 3. The sniff report (Step 5) should note: "Cookie auth validated — the generated CLI will support `auth login --chrome`."
+
+6. **On Fail:** Inform the user via the conversation:
+ > "Authenticated endpoints were discovered, but cookie replay failed (likely CSRF tokens or strict cookie policies). The generated CLI will include these endpoints but won't offer `auth login --chrome`. You'll need to manually provide auth tokens via environment variables."
+
+ Set `Auth.Type = "none"` in the capture's auth section. Include the authenticated endpoints in the spec (they're still valid endpoints), but the CLI won't have a browser auth path. Note the failure reason in the sniff report.
+
#### Step 3: Analyze capture
Run websniff on the captured traffic:
@@ -457,7 +704,7 @@ The report must contain these sections:
3. **Sniff Configuration** — Backend used (browser-use, agent-browser, or manual HAR), pacing settings (initial delay, final effective rate), and proxy pattern detection result (proxy-envelope detected / not detected, with the proxy URL if applicable).
-4. **Endpoints Discovered** — A markdown table with columns: Method, Path, Status Code, Content-Type. One row per unique endpoint observed.
+4. **Endpoints Discovered** — A markdown table with columns: Method, Path, Status Code, Content-Type, Auth. One row per unique endpoint observed. The Auth column is "public" or "auth-required" (based on Step 2a.1.5 classification). If no authenticated flow was run, omit the Auth column.
5. **Coverage Analysis** — What resource types were exercised (e.g., "collections, workspaces, teams, categories") and what was likely missed. Compare against the Phase 1 research brief to identify gaps (e.g., "Brief mentions 'flows' but no flow endpoints were discovered during sniff").
@@ -468,4 +715,6 @@ The report must contain these sections:
7. **Rate Limiting Events** — Any 429 responses encountered, delays applied, and effective sniff rate achieved (e.g., "Sniffed 7 endpoints at ~1.5 req/s effective rate, one 429 at request #4").
-8. **Authentication Context** — Whether the sniff used an authenticated session. If yes: transfer method used (auto-connect / profile / headed login / HAR), which endpoints were only reachable with auth (e.g., "order history, saved addresses, rewards required login"), and confirmation that session state was excluded from manuscript archiving. If no: "No authenticated session used."
+8. **Authentication Context** — Whether the sniff used an authenticated session. If yes: transfer method used (auto-connect / profile / headed login / HAR), which endpoints were only reachable with auth (e.g., "order history, saved addresses, rewards required login"), the auth header scheme discovered (e.g., "Authorization: PagliacciAuth {customerId}|{authToken}", "Bearer token from localStorage"), and confirmation that session state was excluded from manuscript archiving. If no: "No authenticated session used."
+
+9. **Bundle Extraction** — If JS bundle extraction ran (Step 2a.2.7), list: the bundle URL analyzed, the API base URL discovered, endpoints found only in the bundle (not during interactive sniff), and any API config extracted (version headers, auth construction patterns). If bundle extraction did not run, omit this section.
← 10150ad3 feat(cli): runstate isolation and lock lifecycle for paralle
·
back to Cli Printing Press
·
feat(cli): browser auth, composed cookies, smart output, and 6d2d059d →