[object Object]

← back to Cli Printing Press

feat(cli): browser auth, composed cookies, smart output, and sniff robustness (#115)

6d2d059d35cc1d1d4835d46be2e8e7ff8550a5f8 · 2026-04-03 11:43:37 -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(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>

* docs(cli): add machine vs printed CLI guidance to AGENTS.md

Clarifies the distinction between machine changes (generator, templates,
skills — generalized, affect all future CLIs) and printed CLI changes
(specific to one API). Default to machine changes; only fix printed CLIs
directly when the issue is genuinely API-specific.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): case-insensitive --select matching with kebab-case support

--select now matches field names case-insensitively and converts
camelCase/PascalCase API field names to kebab-case for comparison.
"--select id,order-date,summary,price" now matches API fields
ID, OrderDate, Summary, Price.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): prefer browser-use over agent-browser for live cookie extraction

agent-browser --auto-connect returns empty string from eval in
auto-connect mode — it can navigate but can't access the page's cookie
context. browser-use --connect has full DOM access and successfully
reads document.cookie. Swap the fallback order so browser-use is tried
first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): validate composed auth before saving — catch expired sessions

After composing the auth header from cookies, makes a lightweight test
request to the API before saving to config. If the API returns 401/403,
reports "session expired" and directs the user to log in again. Prevents
silently saving stale credentials that fail on every subsequent command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(cli): mark composed auth plan complete, add auto-table retro finding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): three Codex review fixes for composed cookie auth

1. specgen: add composed case to detectCapturedAuth so sniff-produced
   captures with Type=composed propagate through to the spec. Without
   this, composed auth was silently dropped to auth.type=none.

2. auth_browser.go.tmpl: auto-select Chrome profile with most cookies
   when non-interactive (piped stdout / --no-input). Only prompts for
   profile selection in a real terminal. Prevents hanging in CI/agents.

3. auth_browser.go.tmpl: replace hardcoded /Version endpoint in
   validateComposedAuth with a HEAD request to the API base URL.
   /Version was Pagliacci-specific; the base URL is generic and still
   catches 401/403 for expired sessions.

Also adds Format field to AuthCapture struct for composed auth support.

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

Diff

commit 6d2d059d35cc1d1d4835d46be2e8e7ff8550a5f8
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri Apr 3 11:43:37 2026 -0700

    feat(cli): browser auth, composed cookies, smart output, and sniff robustness (#115)
    
    * 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(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>
    
    * docs(cli): add machine vs printed CLI guidance to AGENTS.md
    
    Clarifies the distinction between machine changes (generator, templates,
    skills — generalized, affect all future CLIs) and printed CLI changes
    (specific to one API). Default to machine changes; only fix printed CLIs
    directly when the issue is genuinely API-specific.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): case-insensitive --select matching with kebab-case support
    
    --select now matches field names case-insensitively and converts
    camelCase/PascalCase API field names to kebab-case for comparison.
    "--select id,order-date,summary,price" now matches API fields
    ID, OrderDate, Summary, Price.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): prefer browser-use over agent-browser for live cookie extraction
    
    agent-browser --auto-connect returns empty string from eval in
    auto-connect mode — it can navigate but can't access the page's cookie
    context. browser-use --connect has full DOM access and successfully
    reads document.cookie. Swap the fallback order so browser-use is tried
    first.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): validate composed auth before saving — catch expired sessions
    
    After composing the auth header from cookies, makes a lightweight test
    request to the API before saving to config. If the API returns 401/403,
    reports "session expired" and directs the user to log in again. Prevents
    silently saving stale credentials that fail on every subsequent command.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * docs(cli): mark composed auth plan complete, add auto-table retro finding
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): three Codex review fixes for composed cookie auth
    
    1. specgen: add composed case to detectCapturedAuth so sniff-produced
       captures with Type=composed propagate through to the spec. Without
       this, composed auth was silently dropped to auth.type=none.
    
    2. auth_browser.go.tmpl: auto-select Chrome profile with most cookies
       when non-interactive (piped stdout / --no-input). Only prompts for
       profile selection in a real terminal. Prevents hanging in CI/agents.
    
    3. auth_browser.go.tmpl: replace hardcoded /Version endpoint in
       validateComposedAuth with a HEAD request to the API base URL.
       /Version was Pagliacci-specific; the base URL is generic and still
       catches 401/403 for expired sessions.
    
    Also adds Format field to AuthCapture struct for composed auth support.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 AGENTS.md                                          |  13 +
 ...026-04-03-002-feat-composed-cookie-auth-plan.md |   2 +-
 docs/retros/2026-04-03-pagliacci-retro.md          |  11 +
 internal/generator/templates/auth_browser.go.tmpl  |  67 ++++-
 internal/generator/templates/helpers.go.tmpl       | 277 ++++++++++++++++++---
 internal/websniff/specgen.go                       |  12 +
 internal/websniff/types.go                         |   3 +-
 7 files changed, 342 insertions(+), 43 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 305b0988..7de64dde 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,18 @@
 # CLI Printing Press - Development Conventions
 
+## Machine vs Printed CLI — What Are You Optimizing?
+
+This repo contains **the machine** (generator, templates, binary, skills) that produces **printed CLIs**. When fixing bugs or adding features, always ask: is this a machine change or a printed CLI change?
+
+- **Machine changes** (generator, templates, parser, skills) affect every future CLI. They must be generalized — think about how the fix applies across different APIs, spec formats, and auth patterns, not just the CLI you're looking at right now.
+- **Printed CLI changes** (code in `~/printing-press/library/<cli>/`) fix one specific CLI. These are fine for targeted improvements but don't compound.
+
+**Default to machine changes.** If a problem shows up in a printed CLI, the first question is: should the generator have gotten this right? If yes, fix the machine so every future CLI benefits. Only fix the printed CLI directly when the issue is genuinely specific to that one API.
+
+**Never change the machine for one CLI's edge case** unless explicitly told to. If a fix only helps Pagliacci but would be wrong for Stripe, it doesn't belong in the generator. Add a conditional with a clear guard, or leave it as a printed-CLI-level fix.
+
+**When iterating on a printed CLI to discover issues**, note which problems are systemic (retro findings) vs specific. The retro → plan → implement loop exists to feed discoveries from individual CLIs back into the machine.
+
 ## Build, Test & Lint
 
 ```bash
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
index da00fc8d..074a1be4 100644
--- 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
@@ -1,7 +1,7 @@
 ---
 title: "feat: Composed cookie auth for sniff-discovered APIs"
 type: feat
-status: active
+status: completed
 date: 2026-04-03
 origin: docs/brainstorms/2026-04-03-composed-cookie-auth-requirements.md
 ---
diff --git a/docs/retros/2026-04-03-pagliacci-retro.md b/docs/retros/2026-04-03-pagliacci-retro.md
index ecda3581..02b057f0 100644
--- a/docs/retros/2026-04-03-pagliacci-retro.md
+++ b/docs/retros/2026-04-03-pagliacci-retro.md
@@ -209,3 +209,14 @@ No scorer bugs identified in this run.
 - **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.
+
+### 6. Auto-table shows wrong columns for complex responses (template gap)
+
+- **What happened:** `order-list list-orders` displays a table with `BUILDING, BUILDINGNAME, CLONEABLE, COMMENTS, DELTYPE, DELIVERY` — the least useful fields. The response has 22 fields including `OrderDate`, `Summary`, `Price`, `StatusCode`, but the auto-table picks columns alphabetically and stops at terminal width. The useful fields are past the cutoff.
+- **Root cause:** `printAutoTable` in `helpers.go.tmpl` has no concept of field importance. It renders all scalar fields alphabetically. For responses with many fields (>8) or nested arrays (`Summary: ["1 Pizza", "1 Pagliaccio"]`), the table format itself is wrong — a sectional/card layout per item would be better.
+- **Cross-API check:** Every API with complex list responses (orders, invoices, projects with metadata). Simple resources (stores: ID, Name, Address) work fine in tables.
+- **Frequency:** Most APIs — any list endpoint with >8 fields per item.
+- **Fallback if machine doesn't fix it:** User uses `--json | jq` or `--select id,order-date,status,price`. Works but defeats the point of human-friendly output.
+- **Worth a machine fix?** Yes. The heuristic: table for ≤8 scalar fields with no nested arrays; sectional/card format for complex responses with many fields or nested data.
+- **Durable fix:** Add format detection to `printAutoTable` in the helpers template. When the response has >8 fields or contains nested arrays/objects, switch to a sectional layout that shows each item as an indented block with labeled fields, prioritizing high-gravity fields (ID, name/title, date, status, price/amount) first.
+- **Evidence:** Pagliacci order-list shows Building/Cloneable instead of Date/Summary/Price.
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index 266f0b28..fbe21eae 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -187,6 +187,21 @@ profile by name when the installed backend supports it.`,
 				composed = strings.ReplaceAll(composed, "{"+name+"}", cookieMap[name])
 			}
 
+			// Validate the composed auth before saving — catch stale/expired sessions
+			fmt.Fprintf(w, "Validating session...")
+			if err := validateComposedAuth(composed); err != nil {
+				loginURL := "https://" + strings.TrimPrefix(domain, ".")
+				fmt.Fprintf(w, " %s\n", red("expired"))
+				fmt.Fprintln(w, "")
+				fmt.Fprintln(w, "Found cookies but the session has expired.")
+				fmt.Fprintln(w, "Log in again at:")
+				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("session expired for %s", domain))
+			}
+			fmt.Fprintf(w, " %s\n", green("valid"))
+
 			cfg, err := config.Load(flags.configPath)
 			if err != nil {
 				return configErr(err)
@@ -477,7 +492,17 @@ func resolveChromeProfile(w io.Writer, r io.Reader, domain, profileFlag string)
 		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
+		// Multiple profiles have cookies.
+		// Non-interactive: pick the profile with the most cookies (already sorted).
+		// Interactive: ask the user.
+		if !isTerminal(w) {
+			// Non-interactive — auto-select the best profile (most cookies, first in sorted list)
+			selected := withCookies[0]
+			fmt.Fprintf(w, "Auto-selected Chrome profile: %s (%s, %d cookies)\n", selected.DisplayName, selected.Dir, selected.CookieCount)
+			fmt.Fprintf(w, "Use --profile to select a different profile.\n")
+			return selected.Dir, nil
+		}
+
 		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)
@@ -664,6 +689,34 @@ func extractViaCookieScoop(domain, profileDir string) (string, error) {
 	return result, nil
 }
 
+{{- if eq .Auth.Type "composed"}}
+// validateComposedAuth makes a lightweight test request to verify the session is still active.
+// Uses the API base URL — any authenticated endpoint returning 401/403 indicates expiry.
+// A non-auth error (404, 500, timeout) is treated as "can't validate, assume OK" to avoid
+// blocking auth on unrelated API issues.
+func validateComposedAuth(authHeader string) error {
+	testURL := "{{.BaseURL}}"
+	req, err := http.NewRequest("HEAD", testURL, nil)
+	if err != nil {
+		return nil // can't validate, assume OK
+	}
+	req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
+	req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
+
+	client := &http.Client{Timeout: 5 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil // network error, assume OK — don't block auth on connectivity
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == 401 || resp.StatusCode == 403 {
+		return fmt.Errorf("HTTP %d", resp.StatusCode)
+	}
+	return nil
+}
+{{- end}}
+
 // --- Live browser cookie extraction (session cookies fallback) ---
 
 // extractLiveCookies reads document.cookie from a live Chrome session.
@@ -671,17 +724,17 @@ func extractViaCookieScoop(domain, profileDir string) (string, error) {
 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)
+	// 1. Try browser-use --connect (preferred — has full DOM/cookie access)
+	if _, err := exec.LookPath("browser-use"); err == nil {
+		cookies, err := extractViaBrowserUse(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)
+	// 2. Try agent-browser --auto-connect (fallback — limited eval in auto-connect mode)
+	if _, err := exec.LookPath("agent-browser"); err == nil {
+		cookies, err := extractViaAgentBrowser(targetURL)
 		if err == nil && cookies != "" {
 			return cookies, nil
 		}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 3dd187c6..6effd2ad 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -15,6 +15,7 @@ import (
 	"sort"
 	"strings"
 	"text/tabwriter"
+	"unicode"
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
@@ -350,7 +351,8 @@ func filterFields(data json.RawMessage, fields string) json.RawMessage {
 	for _, f := range strings.Split(fields, ",") {
 		f = strings.TrimSpace(f)
 		if f != "" {
-			wanted[f] = true
+			// Normalize to lowercase for case-insensitive matching
+			wanted[strings.ToLower(f)] = true
 		}
 	}
 	if len(wanted) == 0 {
@@ -364,7 +366,7 @@ func filterFields(data json.RawMessage, fields string) json.RawMessage {
 		for i, item := range items {
 			m := map[string]json.RawMessage{}
 			for k, v := range item {
-				if wanted[k] {
+				if fieldMatchesSelect(k, wanted) {
 					m[k] = v
 				}
 			}
@@ -379,7 +381,7 @@ func filterFields(data json.RawMessage, fields string) json.RawMessage {
 	if json.Unmarshal(data, &obj) == nil {
 		m := map[string]json.RawMessage{}
 		for k, v := range obj {
-			if wanted[k] {
+			if fieldMatchesSelect(k, wanted) {
 				m[k] = v
 			}
 		}
@@ -390,6 +392,37 @@ func filterFields(data json.RawMessage, fields string) json.RawMessage {
 	return data
 }
 
+// fieldMatchesSelect checks if a field name matches any of the wanted select fields.
+// Matches case-insensitively and converts camelCase/PascalCase to kebab-case for comparison.
+// "OrderDate" matches "orderdate", "order-date", or "OrderDate".
+func fieldMatchesSelect(fieldName string, wanted map[string]bool) bool {
+	lower := strings.ToLower(fieldName)
+	// Direct case-insensitive match: "ID" matches "id"
+	if wanted[lower] {
+		return true
+	}
+	// Convert original casing to kebab-case: "OrderDate" → "order-date"
+	kebab := camelToKebab(fieldName)
+	if kebab != lower && wanted[kebab] {
+		return true
+	}
+	return false
+}
+
+// camelToKebab converts "orderDate" or "orderdate" to "order-date" by splitting on
+// uppercase boundaries. For already-lowercase input, splits on known word boundaries.
+func camelToKebab(s string) string {
+	var b strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			b.WriteByte('-')
+		}
+		b.WriteRune(unicode.ToLower(r))
+	}
+	return b.String()
+}
+
 // printOutputWithFlags routes output through the right format based on flags.
 func printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {
 	// Apply --compact: filter to high-gravity fields only
@@ -703,54 +736,91 @@ func prioritizeAllHeaders(item map[string]any) []string {
 
 // prioritizeFields orders fields by importance: identity → temporal → status → other.
 // When includeComplex is true, arrays and objects are included (for card layout).
+//
+// Uses exact-or-suffix matching to avoid false positives: "name" matches "Name" and
+// "UserName" but not "BuildingName" (because "Building" is not a known prefix that
+// indicates identity). The field is split on camelCase/snake_case boundaries and the
+// LAST segment is matched against patterns.
 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"},
-	}
+	// Priority tiers — matched against the last segment of the field name.
+	// "OrderDate" → last segment "date" → tier 1 (temporal).
+	// "BuildingName" → last segment "name" → tier 0... but we want to avoid this.
+	// Solution: exact match on the full lowered name OR suffix segment match,
+	// with a penalty for compound names that have a non-identity prefix.
+	type pattern struct {
+		word string
+		tier int
+	}
+	// Exact matches (full field name, case-insensitive) — highest confidence
+	exactMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0, "slug": 0, "key": 0,
+		"date": 1, "created": 1, "updated": 1, "createdat": 1, "updatedat": 1,
+		"status": 2, "state": 2, "statuscode": 2,
+		"summary": 3, "description": 3, "price": 3, "amount": 3, "total": 3,
+		"cost": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "email": 4, "phone": 4, "url": 4,
+	}
+	// Suffix patterns — match when the field ends with this word (after splitting)
+	suffixMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0,
+		"date": 1, "time": 1,
+		"status": 2, "state": 2, "code": 2,
+		"price": 3, "amount": 3, "total": 3, "cost": 3,
+		"summary": 3, "description": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "method": 4,
+	}
+
+	numTiers := 5
 
 	type scored struct {
 		name  string
 		tier  int
-		index int // preserve order within same tier
+		index int
 	}
 
 	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
 			}
 		}
+		// Skip values that won't render usefully in cards
+		if includeComplex {
+			formatted := formatCellValue(v)
+			if formatted == "" {
+				continue
+			}
+		}
 
-		tier := len(tiers) // default: lowest priority
+		tier := numTiers // default: unclassified
 		lower := strings.ToLower(k)
-		for t, patterns := range tiers {
-			for _, p := range patterns {
-				if strings.Contains(lower, p) {
-					if t < tier {
+
+		// 1. Exact match on full field name
+		if t, ok := exactMatches[lower]; ok {
+			tier = t
+		} else {
+			// 2. Split camelCase into segments and match the last one
+			segments := splitCamelCase(lower)
+			if len(segments) > 0 {
+				lastSeg := segments[len(segments)-1]
+				if t, ok := suffixMatches[lastSeg]; ok {
+					// Compound names with identity suffixes (BuildingName, TipTime)
+					// get demoted one tier because the prefix dilutes the signal
+					if len(segments) > 1 {
+						tier = t + 1
+					} else {
 						tier = t
 					}
 				}
 			}
 		}
-		// Demote booleans — they're rarely the most useful columns
-		if _, ok := v.(bool); ok && tier == len(tiers) {
-			tier = len(tiers) + 1
+
+		// Demote booleans to last
+		if _, ok := v.(bool); ok && tier >= numTiers {
+			tier = numTiers + 1
 		}
 		all = append(all, scored{name: k, tier: tier, index: idx})
 		idx++
@@ -770,6 +840,34 @@ func prioritizeFields(item map[string]any, includeComplex bool) []string {
 	return headers
 }
 
+// splitCamelCase splits "OrderDate" → ["order", "date"], "statusCode" → ["status", "code"],
+// "page_size" → ["page", "size"].
+func splitCamelCase(s string) []string {
+	var segments []string
+	var current strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if r == '_' || r == '-' {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+			continue
+		}
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+		}
+		current.WriteRune(unicode.ToLower(r))
+	}
+	if current.Len() > 0 {
+		segments = append(segments, current.String())
+	}
+	return segments
+}
+
 // 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 {
@@ -802,16 +900,16 @@ func printAutoCards(w io.Writer, items []map[string]any) error {
 		}
 
 		// 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
+			// Multi-line values (nested arrays) start with \n
+			if strings.HasPrefix(v, "\n") {
+				fmt.Fprintf(w, "  %s:%s\n", h, v)
+			} else {
+				fmt.Fprintf(w, "  %-*s  %s\n", maxLen, h+":", v)
 			}
 		}
 	}
@@ -821,17 +919,29 @@ func printAutoCards(w io.Writer, items []map[string]any) error {
 func formatCellValue(v any) string {
 	switch val := v.(type) {
 	case string:
+		// Format ISO dates as just the date portion
+		if len(val) >= 19 && val[4] == '-' && val[7] == '-' && val[10] == 'T' {
+			return val[:10]
+		}
 		return truncate(val, 60)
 	case float64:
 		if val == float64(int64(val)) {
 			return fmt.Sprintf("%d", int64(val))
 		}
-		return fmt.Sprintf("%g", val)
+		return fmt.Sprintf("%.2f", val)
 	case bool:
 		return fmt.Sprintf("%t", val)
 	case nil:
 		return ""
 	case []any:
+		if len(val) == 0 {
+			return ""
+		}
+		// If array contains objects, format each as a summary line
+		if obj, isObj := val[0].(map[string]any); isObj {
+			_ = obj
+			return formatObjectArray(val)
+		}
 		// Flatten simple arrays into comma-separated string
 		parts := make([]string, 0, len(val))
 		for _, item := range val {
@@ -843,8 +953,107 @@ func formatCellValue(v any) string {
 			}
 		}
 		return truncate(strings.Join(parts, ", "), 60)
+	case map[string]any:
+		return formatSingleObject(val)
 	default:
 		b, _ := json.Marshal(val)
 		return truncate(string(b), 60)
 	}
 }
+
+// formatObjectArray renders an array of objects as multi-line summary.
+// Each object is summarized by its most descriptive fields: name/title, qty, size, price.
+func formatObjectArray(items []any) string {
+	var lines []string
+	for _, raw := range items {
+		obj, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		lines = append(lines, formatObjectSummary(obj))
+	}
+	if len(lines) == 0 {
+		return ""
+	}
+	// Multi-line: newline-prefixed so the card renderer can indent
+	return "\n" + strings.Join(lines, "\n")
+}
+
+// formatObjectSummary extracts the most useful fields from an object into a one-line summary.
+// Looks for: qty/count → name/title → size → price, in that order.
+func formatObjectSummary(obj map[string]any) string {
+	var parts []string
+
+	// Quantity
+	qty := findField(obj, "qty", "count", "quantity")
+	if qty != "" && qty != "1" && qty != "0" {
+		parts = append(parts, qty+"x")
+	} else if qty == "1" {
+		parts = append(parts, "1x")
+	}
+
+	// Name — check nested objects too (e.g., Side1.Name)
+	name := findField(obj, "name", "title", "label", "description")
+	if name == "" {
+		// Check nested objects for name
+		for _, key := range []string{"Side1", "side1", "Item", "item", "Product", "product"} {
+			if nested, ok := obj[key].(map[string]any); ok {
+				name = findField(nested, "name", "title", "label")
+				if name != "" {
+					break
+				}
+			}
+		}
+	}
+	if name != "" {
+		parts = append(parts, name)
+	}
+
+	// Size
+	size := findField(obj, "sizename", "size_name")
+	if size == "" {
+		size = findField(obj, "catname", "cat_name", "category")
+	}
+	if size != "" {
+		parts = append(parts, "—")
+		parts = append(parts, size)
+	}
+
+	// Price
+	price := findField(obj, "extprice", "price", "amount", "total")
+	if price != "" && price != "0" {
+		parts = append(parts, fmt.Sprintf("($%s)", price))
+	}
+
+	if len(parts) == 0 {
+		// Fallback: JSON summary
+		b, _ := json.Marshal(obj)
+		return truncate(string(b), 80)
+	}
+	return "    " + strings.Join(parts, " ")
+}
+
+// formatSingleObject renders a single object by its most descriptive fields.
+func formatSingleObject(obj map[string]any) string {
+	name := findField(obj, "name", "title", "label", "description")
+	if name != "" {
+		return name
+	}
+	id := findField(obj, "id", "key", "code")
+	if id != "" {
+		return id
+	}
+	return ""
+}
+
+// findField searches an object for a field name (case-insensitive) and returns its formatted value.
+func findField(obj map[string]any, names ...string) string {
+	for _, name := range names {
+		for k, v := range obj {
+			if strings.EqualFold(k, name) {
+				return formatCellValue(v)
+			}
+		}
+	}
+	return ""
+}
diff --git a/internal/websniff/specgen.go b/internal/websniff/specgen.go
index a65a0d21..c64e40ab 100644
--- a/internal/websniff/specgen.go
+++ b/internal/websniff/specgen.go
@@ -323,6 +323,18 @@ func detectCapturedAuth(capture *AuthCapture, envPrefix string) spec.AuthConfig
 				CookieDomain: capture.BoundDomain,
 				EnvVars:      envVarsOrNil(envPrefix, "COOKIES"),
 			}
+		case "composed":
+			headerName := firstAuthHeader(capture.Headers)
+			if headerName == "" {
+				headerName = "Authorization"
+			}
+			return spec.AuthConfig{
+				Type:         "composed",
+				Header:       headerName,
+				Format:       capture.Format,
+				CookieDomain: capture.BoundDomain,
+				Cookies:      capture.Cookies,
+			}
 		}
 	case captureType == "cookie" && len(capture.Cookies) > 0:
 		return spec.AuthConfig{
diff --git a/internal/websniff/types.go b/internal/websniff/types.go
index cc8edd9a..3bdb0c89 100644
--- a/internal/websniff/types.go
+++ b/internal/websniff/types.go
@@ -52,7 +52,8 @@ type EnrichedCapture struct {
 type AuthCapture struct {
 	Headers     map[string]string `json:"headers"`
 	Cookies     []string          `json:"cookies"`
-	Type        string            `json:"type"`
+	Type        string            `json:"type"`   // bearer, api_key, cookie, composed
+	Format      string            `json:"format"` // for composed: header template with {cookieName} placeholders
 	BoundDomain string            `json:"bound_domain"`
 	ExpiresAt   string            `json:"expires_at"`
 }

← b0a38151 feat(cli): add Chrome cookie auth for sniff-discovered APIs  ·  back to Cli Printing Press  ·  fix(cli): scorer recognizes composed/cookie auth and apiKey e9d9daa5 →