[object Object]

← back to Cli Printing Press

feat(cli): add auth doctor subcommand (#226)

72916ac1e571975da741706ea519aa25f60c18db · 2026-04-19 10:15:49 -0700 · Matt Van Horn

* docs: add planning docs for feature exploration and auth doctor

Captures the exploration that led to the auth doctor feature:

- 001: initial composio-inspired feature catalog and stack ranking
- 002: super-CLI run namespace plan (killed; /ppl already handles discovery/install/routing)
- 003: unified auth manager plan (deferred; file store was marginal over env vars)
- 004: auth doctor plan (shipped in follow-up commit)

* feat(cli): add auth doctor subcommand for env-var visibility

printing-press auth doctor scans every installed printed CLI under
~/printing-press/library/<api>/tools-manifest.json and reports whether
its declared env vars are set, unset, or suspicious. Fingerprints show
the first four characters of each set value, never the full token.

Classifications:
- ok: env var set and (for known types) passes minimum-length heuristic
- suspicious: set but looks malformed (too short, leading/trailing whitespace)
- not_set: env var declared in manifest but absent from environment
- no_auth: manifest declares auth type 'none'
- unknown: manifest missing, corrupt, or uses an unfamiliar auth type

Diagnostic only -- exits 0 regardless of findings. --json flag emits
machine-readable output for agent consumption. Offline, read-only, no
network calls.

Plan: docs/plans/2026-04-19-004-feat-auth-doctor-plan.md

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

Files touched

Diff

commit 72916ac1e571975da741706ea519aa25f60c18db
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Sun Apr 19 10:15:49 2026 -0700

    feat(cli): add auth doctor subcommand (#226)
    
    * docs: add planning docs for feature exploration and auth doctor
    
    Captures the exploration that led to the auth doctor feature:
    
    - 001: initial composio-inspired feature catalog and stack ranking
    - 002: super-CLI run namespace plan (killed; /ppl already handles discovery/install/routing)
    - 003: unified auth manager plan (deferred; file store was marginal over env vars)
    - 004: auth doctor plan (shipped in follow-up commit)
    
    * feat(cli): add auth doctor subcommand for env-var visibility
    
    printing-press auth doctor scans every installed printed CLI under
    ~/printing-press/library/<api>/tools-manifest.json and reports whether
    its declared env vars are set, unset, or suspicious. Fingerprints show
    the first four characters of each set value, never the full token.
    
    Classifications:
    - ok: env var set and (for known types) passes minimum-length heuristic
    - suspicious: set but looks malformed (too short, leading/trailing whitespace)
    - not_set: env var declared in manifest but absent from environment
    - no_auth: manifest declares auth type 'none'
    - unknown: manifest missing, corrupt, or uses an unfamiliar auth type
    
    Diagnostic only -- exits 0 regardless of findings. --json flag emits
    machine-readable output for agent consumption. Offline, read-only, no
    network calls.
    
    Plan: docs/plans/2026-04-19-004-feat-auth-doctor-plan.md
    
    ---------
    
    Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
---
 AGENTS.md                                          |   1 +
 README.md                                          |  11 +
 ...-19-001-feat-composio-inspired-features-plan.md | 625 +++++++++++++++++++++
 ...-04-19-002-feat-super-cli-run-namespace-plan.md | 395 +++++++++++++
 ...026-04-19-003-feat-unified-auth-manager-plan.md | 330 +++++++++++
 docs/plans/2026-04-19-004-feat-auth-doctor-plan.md | 230 ++++++++
 internal/authdoctor/classify.go                    | 138 +++++
 internal/authdoctor/classify_test.go               | 194 +++++++
 internal/authdoctor/fingerprint.go                 |  47 ++
 internal/authdoctor/fingerprint_test.go            |  29 +
 internal/authdoctor/render.go                      | 110 ++++
 internal/authdoctor/render_test.go                 |  73 +++
 internal/authdoctor/scan.go                        |  91 +++
 internal/authdoctor/scan_test.go                   | 180 ++++++
 internal/authdoctor/types.go                       |  54 ++
 internal/cli/auth_doctor_cmd.go                    |  53 ++
 internal/cli/auth_doctor_cmd_test.go               |  66 +++
 internal/cli/root.go                               |   1 +
 18 files changed, 2628 insertions(+)

diff --git a/AGENTS.md b/AGENTS.md
index aa55286a..a2415b80 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -72,6 +72,7 @@ Key terms used throughout this repo. Several have overloaded meanings — the gl
 | **shipcheck** | The three-part verification block that gates publishing: dogfood + verify + scorecard, run together. All three must pass before a printed CLI ships. |
 | **scorecard** / **scoring** | Two-tier quality assessment. Tier 1: infrastructure (12 dimensions, 60 pts). Tier 2: domain correctness (6 dimensions, 40 pts). Total /100 with letter grades. Subcommand: `printing-press scorecard`. |
 | **doctor** | Self-diagnostic command shipped inside every printed CLI for end-users to run. Checks environment, auth config, and connectivity at the user's runtime. Unlike dogfood (which validates at generation time), doctor runs post-install. |
+| **auth doctor** | Subcommand on the printing-press binary (`printing-press auth doctor`). Scans every installed printed CLI's `tools-manifest.json` under `~/printing-press/library/<api>/` and reports env-var status (ok / suspicious / not_set / no_auth / unknown) with redacted fingerprints. Diagnostic only — never gates, never probes the network. Lives in `internal/authdoctor/`. |
 | **local library** | `~/printing-press/library/<api-slug>/` — where printed CLIs land after a successful run. Directory is keyed by API slug (e.g., `notion`), not CLI name. Local directory, not a git repo. |
 | **public library repo** | The GitHub repo [`mvanhorn/printing-press-library`](https://github.com/mvanhorn/printing-press-library) — public catalog of finished CLIs organized by category. `/printing-press-publish` pushes here. |
 | **publish (pipeline)** | The pipeline step that moves a working CLI into the local library and writes the `.printing-press.json` provenance manifest. |
diff --git a/README.md b/README.md
index 27aca795..5d586f7d 100644
--- a/README.md
+++ b/README.md
@@ -283,6 +283,17 @@ printing-press scorecard --dir ./hubspot-pp-cli --spec ./hubspot-spec.json
 printing-press dogfood --dir ./hubspot-pp-cli --spec ./hubspot-spec.json
 ```
 
+## Diagnosing Auth
+
+`printing-press auth doctor` scans every installed printed CLI's `tools-manifest.json` and reports whether its declared env vars are set, unset, or suspicious. Fingerprints show the first four characters of each set value, never the full token.
+
+```bash
+printing-press auth doctor
+printing-press auth doctor --json
+```
+
+Useful when an agent hits a 401 on a printed CLI: one command shows whether the token is missing, truncated, or shadowed by a stale value without having to inspect shell config. Offline, read-only, and exits 0 even when findings include "not set" or "suspicious" because this is diagnostic, not gating.
+
 ## Library
 
 Published CLIs live in [printing-press-library](https://github.com/mvanhorn/printing-press-library), organized by category.
diff --git a/docs/plans/2026-04-19-001-feat-composio-inspired-features-plan.md b/docs/plans/2026-04-19-001-feat-composio-inspired-features-plan.md
new file mode 100644
index 00000000..ec713844
--- /dev/null
+++ b/docs/plans/2026-04-19-001-feat-composio-inspired-features-plan.md
@@ -0,0 +1,625 @@
+---
+title: "feat: Composio-inspired features for the Printing Press - stack-ranked absorption plan"
+type: feat
+status: active
+date: 2026-04-19
+deepened: 2026-04-19
+---
+
+# feat: Composio-inspired features for the Printing Press - stack-ranked absorption plan
+
+## Overview
+
+Composio is a hosted agent-integration platform covering 1,000+ toolkits. Their CLI, MCP hosting, trigger system, and unified auth layer are the strongest reference points on the market for what a "super CLI" looks like in 2026. This plan does a deep feature inventory of Composio, cross-references it against what the Printing Press already ships (machine + library + megamcp), stack-ranks the absorbable features, and proposes implementation units for the top tier only.
+
+The plan is intentionally research-first and decision-forward. The stack ranking is the primary artifact. Implementation units are scoped to the S-tier and A-tier items; B and C tiers are deferred to Future Considerations so the user can approve the cut before we start cutting work.
+
+## Problem Frame
+
+The Printing Press is a CLI factory. For any API, it generates a printed CLI plus a matching MCP server with local SQLite, FTS5 search, compound commands, domain archetypes, verify/dogfood/scorecard, and optional sniff-gate discovery. The library ships 21 printed CLIs and an aggregate megamcp server with dynamic activation, setup_guide, and cross-API tool search.
+
+Composio solves a different shape of the same problem. Instead of generating one great local CLI per API, they host one platform that normalises auth, execution, discovery, and event delivery across thousands of APIs. A Composio user runs `composio login` once, runs `composio link linkedin`, and then every agent-framework (Claude Agent SDK, OpenAI Agents, LangChain, MCP clients) has authenticated LinkedIn access without per-API onboarding.
+
+The gap the user wants to close: which Composio features would move the Printing Press forward without breaking its local-first, agent-native, "two binaries per API + no backend" philosophy? That is the question this plan answers.
+
+## Requirements Trace
+
+- R1. Inventory Composio's CLI, MCP, triggers, auth, and ecosystem features in enough depth to make informed decisions.
+- R2. Inventory the Printing Press's current feature set (machine, library, megamcp) in enough depth to spot real gaps.
+- R3. Stack-rank Composio features by absorbability into the Printing Press using explicit criteria (impact, reach, effort, fit, moat).
+- R4. For the top ranked items, produce implementation units concrete enough to hand to `/ce:work` without another planning pass.
+- R5. Explicitly mark out-of-scope items so we do not drift into cloning the parts of Composio that fight PP's philosophy (hosted dashboard, Python sandbox, multi-tenant SaaS).
+
+## Scope Boundaries
+
+In scope:
+- Research synthesis and stack ranking of Composio features worth absorbing.
+- Implementation units for the S-tier and A-tier features.
+- Impact analysis across the machine, printed CLIs, and library/megamcp.
+
+Out of scope (explicit non-goals):
+- Building a hosted backend or dashboard.
+- Python sandbox or remote filesystem for tool execution.
+- Multi-tenant per-user MCP URLs.
+- Rewriting printed CLIs to route through a central server.
+- White-label or branded auth screens.
+- Copying Composio's intent-routing LLM layer (agents already search_tools via megamcp).
+
+## Context & Research
+
+### Composio feature inventory
+
+Collected from composio.dev, docs.composio.dev, their CLI reference page, MCP overview, triggers doc, and the ComposioHQ/skills repo.
+
+CLI surface (from `composio --help` / docs):
+
+| Command | What it does |
+|---------|--------------|
+| `composio login [-y]` | One-time auth with Composio backend. `-y` for CI. |
+| `composio whoami` | Show account + workspace. |
+| `composio link <toolkit>` | Kicks off OAuth for a specific toolkit (LinkedIn, Stripe, Slack, etc.). `--no-wait` prints auth URL and exits. |
+| `composio connected-accounts list --toolkits X` | Inventory of linked accounts. |
+| `composio tools list --toolkit X` | Tool catalog per toolkit. |
+| `composio tools info TOOL_SLUG` | Schema / params / description for one tool. |
+| `composio search "query" [--toolkits X] [--human]` | Cross-toolkit semantic search. JSON by default. |
+| `composio execute TOOL_SLUG -d <json\|@file\|->` | Run a tool. `--dry-run`, `--get-schema`, `--skip-connection-check`, `--parallel`. |
+| `composio proxy --toolkit X -X METHOD /path [-H ...] [-d ...]` | Authenticated raw HTTP using linked creds. |
+| `composio run --file workflow.ts` | Execute a TypeScript/JS workflow file. |
+| `composio generate ts [--toolkits X] [--compact] [--transpiled] [--type-tools]` | Typed SDK codegen. |
+| `composio generate py [--toolkits X]` | Python codegen. |
+| `composio dev init` | Initialise local dev context. |
+| `composio dev playground-execute --user-id X` | Test against playground user. |
+| `composio dev listen --toolkits X [--table]` | Live-tail triggers and logs. |
+| `composio dev logs tools` / `logs triggers` | Historical logs. |
+| `composio artifacts cwd` | Path to session artifacts. |
+| Env: `COMPOSIO_API_KEY`, `COMPOSIO_BASE_URL`, `COMPOSIO_CACHE_DIR`, `COMPOSIO_SESSION_DIR`, `COMPOSIO_LOG_LEVEL`, `COMPOSIO_DISABLE_TELEMETRY`, webhook secret. |
+
+MCP surface:
+- Server config created via dashboard or API, exposes a subset of toolkits and `allowed_tools`.
+- `composio.mcp.generate(user_id, mcp_config_id)` returns a per-user URL: `https://backend.composio.dev/v3/mcp/SERVER_ID?user_id=USER_ID`.
+- Requires `x-api-key` header when `require_mcp_api_key` is on.
+- Works with any MCP client (Claude Desktop, Cursor, OpenAI Agents, Windsurf, Cline).
+
+Triggers surface:
+- Trigger types per toolkit (e.g. `GITHUB_COMMIT_EVENT`, `SLACK_NEW_MESSAGE`, `GMAIL_NEW_EMAIL`).
+- Webhook delivery for apps with native webhooks; polling (15-min floor) otherwise.
+- Create trigger instance scoped to user + connected account + config params.
+- Webhook signature verification built-in.
+
+Agent-framework integrations: Claude Agent SDK, Anthropic SDK, OpenAI Agents, OpenAI, Gemini, Vercel AI, LangChain, LangGraph, CrewAI, LlamaIndex, Mastra, Cloudflare Workers. Python and TypeScript parity for most.
+
+Claude Code integration: `npx skills add composiohq/skills` installs skills for tool-router, auth, toolkits, triggers. Heavy emphasis on "identify user, create session, get tools" as the canonical flow.
+
+Security posture: SOC2 + ISO 27001:2022, bring-your-own-cloud, fine-grained data access controls, managed OAuth rotation, scoped inline authorization.
+
+### Printing Press feature inventory (current state, 2026-04-19)
+
+Machine (`cli-printing-press`):
+- Generates `<api>-pp-cli` (Cobra) plus `<api>-pp-mcp` (MCP server) from OpenAPI 3, GraphQL SDL, HAR, or internal YAML spec.
+- Agent-native flags: `--json`, `--select`, `--dry-run`, `--stdin`, `--csv`, `--compact`, `--quiet`, `--yes`, `--no-input`, `--no-cache`, `--no-color`. Auto-JSON when piped. Typed exits (0/2/3/4/5/7).
+- Domain archetypes (PM, Communication, Payments, Infrastructure, Content) auto-generate `stale`, `orphans`, `load`, `reconcile`, `health`, `similar`, `channel-health`.
+- Local-first data layer: domain-specific SQLite tables, FTS5, cursor sync, `sync`/`search`/`sql`/`tail`.
+- Quality pipeline: dogfood (structural), verify (runtime), scorecard (two-tier), shipcheck (combined), emboss, polish, retro.
+- Codex mode for token savings, sniff gate for no-spec APIs, crowd-sniff for ecosystem-absorb manifest.
+- 18 APIs in catalog (Asana, DigitalOcean, Discord, Front, GitHub, HubSpot, LaunchDarkly, Pipedrive, Plaid, Postman, SendGrid, Sentry, Square, Stripe, Stytch, Telegram, Twilio, Petstore).
+
+Library (`printing-press-library`):
+- 21 printed CLIs + 19 MCP servers + `/ppl` router skill + 21 `pp-*` focused skills.
+- Plugin marketplace for Claude Code.
+- Per-CLI auth today is env-var based: `ESPN_KEY`, `HUBSPOT_ACCESS_TOKEN`, `DUB_TOKEN`, `LINEAR_API_KEY`, `CAL_COM_TOKEN`, `KALSHI_API_KEY`, etc. No shared credential store.
+- Auth types present across the catalog: `none`, `api_key`, `bearer_token`, `composed` (cookie auth), OAuth implied for some (e.g. Linear). No unified OAuth helper.
+
+Megamcp (`internal/megamcp/`):
+- Aggregate MCP server that loads all printed MCP manifests and exposes 6 meta-tools: `library_info`, `setup_guide`, `activate_api`, `deactivate_api`, `search_tools`, `about`.
+- Dynamic tool registration/deregistration via `ActivationManager`.
+- Fail-closed auth via `hasAuthConfigured(manifest)` and `ApplyAuthFormat` (placeholder substitution from env vars).
+- Max 32KB response to agent, 10MB over the wire. Response classification for error telemetry.
+- Already solves a large chunk of the "Composio-style unified MCP server" problem on the MCP side.
+
+Gap summary (the interesting bit):
+- CLI side has no unified `pp execute`, `pp search`, `pp proxy`. Every tool lives behind its printed CLI binary (e.g. `espn-pp-cli scores`) or the `/pp-espn` skill. There is no cross-CLI execution entry point to parallel megamcp's MCP-side unification.
+- No central auth manager. Each printed CLI reads its own env vars. Users manage many tokens. No OAuth helper, no token refresh, no keychain story.
+- No triggers / event delivery. Polling is manual via `sync` commands. No webhook receiver.
+- No `--parallel` batch execution across tools.
+- No `allowed_tools` filter on MCP servers.
+- No per-CLI `llms.txt` artefact.
+
+### External references
+
+- Composio CLI reference: https://composio.dev/toolkits/linkedin/framework/cli
+- Composio docs: https://docs.composio.dev
+- Composio skills for Claude Code: https://github.com/ComposioHQ/skills
+- Composio monorepo: https://github.com/ComposioHQ/composio
+- Related prior PP plan: `docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md` (already shipped).
+
+### Institutional learnings referenced
+
+- PP philosophy "absorb then transcend" (README L84-94): absorb every feature from every competitor, then compound with SQLite + agent-native layer. Composio absorption lives at the root of this philosophy.
+- `AGENTS.md` machine-vs-printed discipline: many Composio features are machine-level (generator changes that affect every future CLI); fewer are printed-CLI-level.
+- Glossary: megamcp shows the pattern for aggregate servers and meta-tools. It is the right place to extend for `allowed_tools` and parallel activation.
+
+## Key Technical Decisions
+
+KTD-1. Landing repo for the super-CLI entry point is `cli-printing-press` (not `printing-press-library`). Rationale: the command surface is a machine capability that must ship to every user the moment they install the press. Putting it in the library couples it to plugin install order. It belongs next to the generator binary so it travels together.
+
+KTD-2. The super-CLI takes the shape of subcommands on the existing `printing-press` binary: `printing-press execute`, `printing-press search`, `printing-press info`, `printing-press proxy`, `printing-press list`, `printing-press auth ...`, `printing-press trigger ...`. Rationale: one binary to install, one `/install mvanhorn/cli-printing-press` to get everything. A separate `pp` binary would duplicate distribution, release tooling, and updater concerns. A `/ppl` skill alias can still surface the same commands for humans.
+
+KTD-3. The unified auth layer is additive. Printed CLIs continue to read env vars as primary. A new `auth resolve <api>` helper is read by generator templates and returns the first available credential in order: process env -> `PP_AUTH_FILE` override -> shared secure store -> keychain. Rationale: every existing printed CLI keeps working with zero template changes on the hot path. Migration is pull, not push.
+
+KTD-4. The shared credential store is file-based at `~/.pp/credentials.json` with `chmod 600`, with a pluggable backend interface so macOS keychain / Linux Secret Service / Windows Credential Manager can be wired in later without changing the public API. Rationale: ship value now; keychain is a P2 follow-up once the interface is stable.
+
+KTD-5. OAuth flows are implemented per-API via a small `authproviders/` registry. Each provider is a Go file with `AuthURL`, `TokenExchange`, `Refresh`, and metadata (scopes, redirect URI handling). Rationale: we cannot reuse Composio's hosted OAuth broker and we will not build one. Per-provider code is honest work and stays under 150 lines per provider for most APIs.
+
+KTD-6. Triggers are polling-first. `printing-press trigger run` is a local daemon that schedules per-trigger polls using existing `sync` cursors from each printed CLI's store, then forwards diffs to the configured sink (webhook URL, stdout-as-JSONL, file). Webhook-receiver mode is a phase-two add for APIs with native webhooks. Rationale: PP already has cursor-based sync everywhere; polling triggers are a 200-line wrapper over that. Webhook receivers require a long-running HTTP server with signature verification and are a larger lift.
+
+KTD-7. `allowed_tools` and `denied_tools` are expressed as printed-MCP flags (`<api>-pp-mcp --allow "tool1,tool2"`) and as megamcp flags (`printing-press mega-mcp --allow espn:scores_get,espn:teams_list`). Rationale: symmetry between the per-API MCP and the aggregate server. Security and token economy both benefit.
+
+KTD-8. Parallel execution is exposed at two layers: `printing-press execute --parallel` accepts `-d @batch.json` where the batch is an array of `{api, tool, params}`, and megamcp adds a `batch_execute` meta-tool that accepts the same shape. Rationale: matches Composio's `--parallel` affordance without new infra.
+
+KTD-9. `printing-press execute` is a raw-HTTP-through-manifest path. It intentionally does NOT invoke local SQLite, compound commands (`stale`, `orphans`, `load`), domain archetypes, FTS5 search, or `--select`/`--compact` projection. When the user wants the local data layer or compound semantics, they must use `<api>-pp-cli` directly. The super-CLI is a discovery + batching + auth-unification surface, not a replacement for the printed CLIs. Rationale: without this boundary, `execute` silently diverges from the printed CLI for the same tool name, and the Unit 2 verification step generates false-positive parity bugs every time a printed CLI ships a new compound command. Adjust Unit 2 verification to parity-on-raw-HTTP-status-and-body only.
+
+KTD-10. Super-CLI verbs live under a dedicated `printing-press run` subcommand group to preserve the top-level namespace for generator-only commands. The surface becomes `printing-press run list`, `run info`, `run search`, `run execute`, `run proxy`. A compact alias `pp run ...` can ship later. Rationale: `list`, `search`, `info` are high-value English verbs that a future generator feature (catalog search, spec info) will want. Grouping under `run` both signals "this is the runtime role" and reserves the top-level space for the generator role.
+
+KTD-11. Manifest schema versioning is a blocking precondition, not a phase-five addition. `tools-manifest.json` gets a `schema_version: int` field (start at 1). The manifest reader in `internal/megamcp/manifest.go` refuses unknown major versions with an actionable error, and refuses older versions when reading features that require a newer version. Rationale: KTD-2 makes every future manifest field a backward-compat contract between two release trains (generator releases vs library-manifest regenerations). Without an explicit schema gate, Unit 5's new trigger-ready field and every other additive field becomes a silent-fail surface.
+
+KTD-12. Shared `internal/apihttp/` returns a transport-neutral `Response` type (`{StatusCode, Body, Headers}`), not an `mcp.CallToolResult`. MCP-side wraps the Response into `mcp.NewToolResultText/Error` and applies the 32KB `maxAgentResponse` truncation in that wrapper. CLI-side maps status codes to typed exits (0/2/3/4/5/7) in its own wrapper. Host validation and credential redaction stay in `apihttp` because both callers need them. Rationale: without this split the shared package grows "cli-mode flag" branches in every response path and the 32KB agent cap silently applies to CLI output.
+
+KTD-13. Auth-resolution read order makes the env-var-wins choice explicit and surfaces divergence rather than hiding it. Order: process env -> `PP_AUTH_FILE` override -> shared store -> keychain -> nil. `auth doctor` treats "env var and store both present with different values" as a yellow finding and names the shell file the env var came from where detectable (`~/.zshrc`, `~/.bashrc`, loaded profile). `auth link` prints a warning after a successful link if the corresponding env var is set in the current shell, with copy-paste `unset` guidance. `auth list` adds a "shadowed by env" column. Rationale: env-var-wins is simple but creates a silent-stale-token class of bugs. The detection surface must be first-class, not left to user intuition.
+
+## Feature Stack Ranking
+
+Scoring criteria, each 1-5:
+- Impact: agent/human experience delta.
+- Reach: how many printed CLIs it lights up.
+- Effort (inverted): lower effort -> higher score.
+- Fit: alignment with local-first, no-backend, agent-native philosophy.
+- Moat: how much harder it is for a thin-wrapper competitor to replicate.
+
+| Rank | Feature | Impact | Reach | Effort | Fit | Moat | Total | Tier |
+|------|---------|--------|-------|--------|-----|------|-------|------|
+| 1 | Unified super-CLI (`printing-press execute`/`search`/`info`/`proxy`/`list`) | 5 | 5 | 4 | 5 | 4 | 23 | S |
+| 2 | Unified auth manager (`auth login`/`auth link`/`auth status`/`auth doctor`) | 5 | 5 | 3 | 4 | 5 | 22 | S |
+| 3 | Local triggers system (`trigger add`/`trigger run`) | 5 | 4 | 3 | 4 | 5 | 21 | A |
+| 4 | `allowed_tools` + `denied_tools` on `<api>-pp-mcp` and megamcp | 3 | 5 | 5 | 5 | 2 | 20 | A |
+| 5 | `--parallel` batch execution across tools (CLI + megamcp) | 4 | 4 | 5 | 4 | 2 | 19 | A |
+| 6 | `printing-press run --file workflow.yaml` (scriptable chains) | 3 | 3 | 2 | 4 | 3 | 15 | B |
+| 7 | Per-CLI `llms.txt` / `llms-full.txt` artefact | 3 | 5 | 5 | 4 | 1 | 18 | B |
+| 8 | Typed SDK codegen (`printing-press generate ts\|py`) | 2 | 3 | 2 | 3 | 2 | 12 | B |
+| 9 | Browser-based playground for printed CLIs | 2 | 3 | 1 | 2 | 2 | 10 | C |
+| 10 | Per-user multi-tenant MCP URL hosting | 2 | 5 | 1 | 1 | 2 | 11 | C |
+| 11 | Python-sandboxed tool execution | 2 | 3 | 1 | 1 | 2 | 9 | C |
+| 12 | Hosted dashboard for connections/triggers/logs | 3 | 5 | 1 | 1 | 2 | 12 | C |
+| 13 | White-label auth screens | 1 | 2 | 2 | 2 | 1 | 8 | C |
+
+Stack-ranking commentary:
+
+S-tier is where PP wins back the ground Composio covers for one-command discovery and one-credential onboarding, without giving up local-first. These are the two features the user will feel in week one.
+
+A-tier adds event-driven workflows, safety (tool filtering), and throughput (parallel). Each is valuable independently; together they turn the printed MCPs into a production-grade agent substrate.
+
+B-tier is nice to have. llms.txt is basically free and should probably ship alongside S-tier. The workflow YAML and codegen can wait until there is user demand signal.
+
+C-tier is where we explicitly refuse to follow Composio. A hosted dashboard and multi-tenant MCP service would fork PP away from its shipped posture; Python sandboxing does not fit Go-binary distribution; white-label auth has no PP customer.
+
+## 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.
+
+### Surface shape for the super-CLI
+
+Super-CLI verbs live under the reserved `run` namespace (KTD-10) to preserve the top-level namespace for generator-only commands.
+
+```
+printing-press                   # existing generator entry point
+  generate ...                   # existing
+  verify ...                     # existing
+  scorecard ...                  # existing
+  emboss ...                     # existing
+  dogfood ...                    # existing
+  mega-mcp [--allow ...] ...     # existing, extended (Unit 4)
+
+  run list                       # new: installed printed CLIs + auth status
+  run info <api> [<tool>]        # new: manifest / schema lookup
+  run search <query> [--api X]   # new: delegates to megamcp-style search across manifests
+  run execute <api> <tool> [-d .]# new: raw-HTTP-through-manifest (KTD-9); does NOT use local SQLite or compound commands
+          [--parallel -d @batch.json]
+          [--dry-run] [--get-schema] [--skip-auth-check]
+  run proxy --api X -X METHOD /path  # new: authenticated raw HTTP using unified auth
+          [-H ...] [-d ...]
+
+  auth login                     # new: no-op placeholder; future SSO
+       link <api>                # new: per-API OAuth (state+PKCE) / api-key capture (tty only)
+       list [--api X]            # new: linked creds + expiry + shadowed-by-env + env-differs columns
+       status                    # new: quick summary
+       doctor                    # new: probe creds + env divergence + perms + sync-path hygiene
+       revoke <api>
+       fix-perms                 # new: restore 0600/0700 on ~/.pp and contents
+       rotate-secret             # new: webhook sink secret rotation (Unit 5)
+
+  trigger add <api> <event>      # new: create a polling trigger
+          --sink webhook|stdout|file
+          --url https://...      # webhook only, https enforced unless --insecure
+          --secret <value>       # webhook only, auto-generated if omitted
+          --every 5m
+          list
+          run                    # daemon mode, HydrateForRequest only
+          logs
+          remove <id>
+          rotate-secret <id> [--overlap 10m]
+```
+
+### Credential resolution order (read path)
+
+```
+hasCred(api) =
+  os.Getenv(primaryEnvVar)          # printed CLI's current path, unchanged
+    || os.Getenv(PP_AUTH_FILE)      # operator override
+    || authstore.Read(api)           # shared JSON file at ~/.pp/credentials.json
+    || keychain.Read(api)            # P2 follow-up, same interface
+    || nil
+```
+
+Two hydration helpers (KTD-13, KTD-3):
+
+- `authload.HydrateForRequest(ctx, slug) -> string` returns the token for use directly in a request header. Token does not land in process env. Preferred for all new printed CLIs and mandatory for the trigger daemon (Unit 5).
+- `authload.Hydrate(slug, envVarName)` populates `os.Setenv` before a CLI's legacy auth code reads it. Kept for already-printed CLIs we do not want to re-emit. Documented as the less-safe path.
+
+Printed CLIs keep their existing env-var semantics. Env-var-wins over store is preserved, but `auth doctor` and `auth list` surface divergence so stale `.zshrc` entries are visible instead of silently shadowing a fresh link.
+
+### Trigger polling loop
+
+```
+for each trigger in store:
+  tick := schedule.Next(trigger)
+  wait until tick
+  cursor := store.GetCursor(trigger.id)
+  diff := printedCLI(trigger.api).Sync(--since cursor, --json)
+  if diff is empty: continue
+  emit(trigger.sink, trigger.event, diff)
+  store.SetCursor(trigger.id, diff.nextCursor)
+```
+
+Sinks: `webhook` POSTs signed JSON; `stdout` writes JSONL for the agent to tail; `file` appends to a rotated log.
+
+### Generator change for future CLIs
+
+Templates add one line: every printed CLI imports `cliutil/authload` and calls `authload.Hydrate("api-slug", "PRIMARY_ENV_VAR")` in root command `PersistentPreRun`. That is the entire surface area in the printed CLI. All the logic lives in the shared package.
+
+## Implementation Units
+
+- [ ] Unit 0: Manifest schema versioning (blocking precondition)
+
+  Goal: Add `schema_version` to `tools-manifest.json`, enforce it in the manifest reader, and establish the backward-compat contract before any new manifest fields ship.
+
+  Requirements: R3, R4, R5
+
+  Dependencies: None. Must land before Units 1, 2, or 5.
+
+  Files:
+  - Modify: `internal/megamcp/manifest.go` to add `SchemaVersion int` on `ToolsManifest` and refuse unknown major versions when reading.
+  - Modify: `internal/pipeline/toolsmanifest.go` to emit `schema_version: 1` on generation.
+  - Modify: the writer path for the `printing-press-library` registry to tolerate mixed-version manifests.
+  - Test: `internal/megamcp/manifest_test.go` extends to cover version-missing, version-current, version-future-major, version-future-minor.
+
+  Approach:
+  - Current version 1. A missing field is treated as version 1 to keep all shipped manifests valid.
+  - Future-major refusal is actionable: "manifest schema_version=2 is newer than this printing-press build; upgrade to at least vX.Y."
+  - Future-minor with unknown fields is accepted with a debug-level log ("ignoring unknown field X in manifest"). Never fail on forward-compat minor bumps.
+
+  Patterns to follow: `internal/megamcp/manifest.go` structure; existing manifest tests.
+
+  Test scenarios:
+  - Happy path: manifest with no `schema_version` loads as v1.
+  - Happy path: manifest with `schema_version: 1` and known fields loads.
+  - Edge case: manifest with `schema_version: 1` and an unknown field (forward-compat minor) loads with one warning log and no error.
+  - Error path: manifest with `schema_version: 2` returns a typed error and a named upgrade instruction; no partial registration.
+  - Integration: megamcp started against a library containing one v1 manifest and one v2 manifest registers only the v1 one and logs the v2 skip at warn level.
+
+  Verification: `printing-press run list` shows accepted and skipped manifests distinctly so users can diagnose version mismatches without reading logs.
+
+- [ ] Unit 1: Super-CLI skeleton - run list, run info, run search
+
+  Goal: Ship `printing-press run list`, `printing-press run info`, `printing-press run search` as thin readers over the existing megamcp registry code, under the reserved `run` namespace (KTD-10).
+
+  Requirements: R3, R4
+
+  Dependencies: Unit 0.
+
+  Files:
+  - Create: `internal/supercli/registry.go` (wrapper reusing `internal/megamcp/registry.go`)
+  - Create: `internal/supercli/list.go`, `internal/supercli/info.go`, `internal/supercli/search.go`
+  - Create: `internal/cli/supercli_cmd.go` (Cobra wiring)
+  - Modify: `cmd/printing-press/main.go` to register the subcommand group
+  - Test: `internal/supercli/registry_test.go`, `internal/supercli/list_test.go`, `internal/supercli/search_test.go`
+
+  Approach:
+  - Share the same manifest loader megamcp uses so a newly-installed printed CLI is discoverable without a re-registration step.
+  - `list` output: table in TTY, JSON when piped. Columns: api, cli-binary, mcp-binary, tool_count, auth_type, auth_status (green/red based on env + store).
+  - `info <api>` returns the full `tools-manifest.json`. `info <api> <tool>` returns a single tool with its parameters.
+  - `search <query>` reuses `makeSearchToolsHandler` logic from megamcp directly; result shape matches.
+
+  Patterns to follow: `internal/megamcp/metatools.go` handlers.
+
+  Test scenarios:
+  - Happy path: `list` returns all 21 library manifests when the library is installed.
+  - Happy path: `info espn` returns the ESPN manifest.
+  - Happy path: `search "scores"` ranks `espn:scores_get` above unrelated tools.
+  - Edge case: `info unknown-api` returns exit 3 and a typed error with suggested APIs.
+  - Edge case: empty library returns `[]` in JSON mode, not an error.
+
+  Verification: `list`, `info`, and `search` all pass `--json | jq` round-trips; auto-JSON triggers when piped.
+
+- [ ] Unit 2: Super-CLI execute + proxy
+
+  Goal: Ship `printing-press run execute <api> <tool>` and `printing-press run proxy --api X -X METHOD /path` under the reserved `run` namespace (KTD-10), explicitly as raw-HTTP-through-manifest paths that do NOT invoke local data layer or compound commands (KTD-9).
+
+  Requirements: R3, R4
+
+  Dependencies: Unit 0, Unit 1.
+
+  Files:
+  - Create: `internal/apihttp/execute.go` with the transport-neutral `Response` type (KTD-12).
+  - Create: `internal/apihttp/hosts.go` (lift host validation from `internal/megamcp/handler.go`).
+  - Create: `internal/apihttp/redact.go` (lift credential redaction).
+  - Modify: `internal/megamcp/handler.go` to wrap `apihttp.Execute` and own the 32KB truncation and MCP-specific result shaping.
+  - Create: `internal/supercli/execute.go`, `internal/supercli/proxy.go`, `internal/supercli/batch.go`.
+  - Modify: `internal/cli/supercli_cmd.go` to wire under `run execute` / `run proxy`.
+  - Test: `internal/apihttp/execute_test.go`, `internal/supercli/execute_test.go`, `internal/supercli/proxy_test.go`, `internal/supercli/batch_test.go`, plus `internal/megamcp/handler_test.go` regression coverage for the wrapper.
+
+  Approach:
+  - `apihttp.Execute(ctx, manifest, tool, args) -> (Response, error)` builds the URL with path-param substitution, applies auth via `internal/authstore` (Unit 3) with env-var fallback, enforces host allowlist, and returns `{StatusCode, Body, Headers}` without any transport-specific shaping.
+  - MCP wrapper applies the 32KB `maxAgentResponse` truncation and returns `mcp.CallToolResult`.
+  - CLI wrapper maps status codes to typed exits: 2xx -> 0, 401/403 -> 4, 404 -> 3, 429 -> 7 (after one backoff), 4xx-other -> 2, 5xx -> 5. Prints table for human, JSON when piped. Does not truncate; the user can pipe to `head` if they want.
+  - `proxy` skips the tool registry, resolves auth for the named API, and makes the raw request. Mirrors curl: `-X`, `-H` (repeatable), `-d`.
+  - All commands honour `--dry-run`, `--get-schema`, `--skip-auth-check`, and auto-JSON.
+  - Execute does not read any printed CLI's SQLite store and does not run compound commands (KTD-9). Help text states this explicitly and points users to `<api>-pp-cli` for those features.
+
+  Execution note: Characterization-first. Write the wrapper test for megamcp's existing behaviour before extracting, assert parity post-extraction, then layer the CLI wrapper. `internal/megamcp/handler.go:28` is the lifting target.
+
+  Patterns to follow: `internal/megamcp/handler.go`, `internal/megamcp/auth.go`.
+
+  Test scenarios:
+  - Happy path: `run execute espn scores_get --league nfl` returns valid JSON.
+  - Happy path: `run proxy --api dub -X GET /links` returns the raw API response body; no local-store augmentation.
+  - Happy path: megamcp wrapper around `apihttp.Execute` still truncates at 32KB; CLI wrapper does not.
+  - Edge case: missing required path param returns exit 2 and names the missing placeholder.
+  - Edge case: `--skip-auth-check` lets the caller hit an unauthenticated endpoint without a store lookup.
+  - Error path: 401 from the API returns exit 4 with an actionable "run `printing-press auth link <api>`" hint.
+  - Error path: 429 with retry-after returns exit 7 after one backoff attempt.
+  - Error path: host not in the manifest's allowlist returns exit 2 with a "host not permitted by manifest" message; same behaviour on both wrappers.
+  - Integration: `run execute --parallel -d @batch.json` runs three tool calls concurrently and returns responses in input order, not completion order.
+  - Integration: extracting `apihttp` does not regress existing megamcp handler tests; characterization suite passes pre and post extraction.
+
+  Verification: parity on raw HTTP status-and-body between `run execute espn X` and `espn-pp-cli X --json` for five tools (KTD-9 scope: bodies only, not `--select`, `--compact`, or compound outputs). Megamcp handler snapshot tests continue to pass after extraction.
+
+- [ ] Unit 3: Unified auth store + `auth` subcommand group
+
+  Goal: Ship `printing-press auth link`, `auth list`, `auth status`, `auth doctor`, `auth revoke`, and the shared credential store that backs them, with explicit threat model, PKCE+state on OAuth, and divergence detection between env vars and store.
+
+  Requirements: R3, R4, R5
+
+  Dependencies: Unit 0, Unit 1.
+
+  Threat model (accepted in P1, revisited at keychain P2):
+  - Same-UID processes (malicious npm/pip postinstall, VS Code extensions, shell plugins) can read `~/.pp/credentials.json`. 0600 does not defend here. Keychain backend (KTD-4) is the mitigation path; P2 gate is "before trigger daemon general availability OR before $100 user, whichever comes first."
+  - Backup and sync exfiltration via Time Machine, iCloud Desktop, Dropbox, rsync. Mitigation: `~/.pp/` directory ships with a `.nosync` marker file and the README documents how to exclude it from common backup tools.
+  - Shell history / argv leakage during api-key prompts. Mitigation: `auth link` reads secrets only from tty with echo disabled, refuses argv-provided secrets (`--token` flag rejected), and scrubs crash reports of argv containing high-entropy strings.
+  - Crash dumps and core files. Mitigation: plan explicitly does not address; documented as out-of-scope for P1.
+  - No at-rest encryption and no file integrity. Mitigation: accepted in P1 with an HMAC-SHA256 over each entry using a device-local secret derived from the hostname and a random salt stored in `~/.pp/.device-key` (0600). Tampering detected, not prevented.
+  - Concurrent writers. Mitigation: single-writer lock via `~/.pp/.credentials.lock` (flock on Linux/macOS, LockFileEx on Windows). Lock contention returns exit 5 with a "another auth operation in progress" message.
+
+  Files:
+  - Create: `internal/authstore/store.go` (file-based backend at `~/.pp/credentials.json`, `chmod 600`, atomic writes)
+  - Create: `internal/authstore/backend.go` (pluggable `Backend` interface; file backend today; keychain stub)
+  - Create: `internal/authstore/providers/` (registry of OAuth providers by api slug)
+  - Create: `internal/authstore/providers/github.go`, `providers/linear.go`, `providers/slack.go` (initial three)
+  - Create: `internal/cli/auth_cmd.go`
+  - Create: `internal/cliutil/authload/` package emitted into every printed CLI
+  - Modify: generator templates to emit one `authload.Hydrate("<slug>", "<PRIMARY_ENV>")` call in root command `PersistentPreRun`
+  - Test: `internal/authstore/store_test.go`, `internal/authstore/providers/github_test.go`, `internal/cliutil/authload/authload_test.go`
+
+  Approach:
+  - Store schema: `{api: {type, token, refresh_token, expires_at, scopes, linked_at, entry_hmac}}`. One file, one slug per key. `entry_hmac` verifies integrity on read.
+  - Parent dir `~/.pp/` is 0700 with an atomic mkdir. `credentials.json` is 0600 written via `O_EXCL` tempfile + rename under the `.credentials.lock`.
+  - `auth link <api>` OAuth flow hardening:
+    - Bind loopback callback on `127.0.0.1` only (never `0.0.0.0` or `localhost` DNS resolution).
+    - Generate a cryptographically random `state` per flow; reject callbacks with missing or mismatching state.
+    - Use PKCE (RFC 7636) with S256 challenge/verifier on every OAuth flow. Providers that do not support PKCE are documented, not silently downgraded.
+    - Exact redirect URI match; plan a per-provider registration table in `internal/authstore/providers/README.md` that names each provider's accepted redirect URI form (fixed-port, `127.0.0.1` wildcard-port, or out-of-band).
+    - Callback server accepts exactly one request, shuts down in under 120 seconds, and times out with exit 4.
+    - Enumerate callback error codes: `access_denied`, `server_error`, `invalid_scope`, `invalid_request`, missing-`code`, non-2xx token-exchange response. Each maps to exit 4 with a distinct message.
+    - OIDC-bearing providers (Google, some enterprise) verify `iss` and `nonce` when ID tokens are present; others skip.
+  - `auth link <api>` api-key mode: tty-only prompt with echo disabled, refuses argv-sourced secrets.
+  - `auth doctor`:
+    - For every installed printed CLI, probe credential presence, expiry, and env-vs-store divergence (KTD-13).
+    - "Env var and store differ" is a yellow finding with the originating shell file where detectable.
+    - Missing 0600 perms on `credentials.json` or 0700 on `~/.pp/` is a red finding with a `fix-perms` suggestion.
+    - Dir listed in a known sync path (`~/Dropbox`, `~/iCloud`, `~/OneDrive`) is a yellow finding with exclusion guidance.
+  - `auth link <api>` post-success: if the corresponding env var is set in the caller's environment, print a warning with copy-paste `unset` guidance; do not silently override.
+  - `auth list` column model: `api`, `type`, `linked_at`, `expires_at`, `scopes`, `shadowed_by_env` (yes/no), `env_differs` (yes/no).
+  - `auth revoke <api>` removes the entry and posts to the provider's revoke endpoint when available.
+  - Generator change is additive and uses `HydrateForRequest` rather than `os.Setenv` for new printed CLIs. Templates import `cliutil/authload` and call `authload.HydrateForRequest(ctx, "<slug>")` inside HTTP client construction so tokens live in request headers and not in process env. The legacy `Hydrate` that populates env vars remains available for backward compatibility with already-printed CLIs.
+  - Child-process guardrails: `authload` documents a `SafeExec` helper that strips `*_TOKEN`/`*_KEY`/`*_SECRET` env vars before spawning subprocesses. Printed-CLI templates that shell out (e.g. to `curl`, `git`, `gh`) must use `SafeExec`, enforced by the scorer.
+  - Trigger daemon (Unit 5) MUST use `HydrateForRequest`, never `Hydrate`, because env-resident tokens in a long-running process are a material escalation.
+
+  Execution note: Test-first on `authstore`. The file layout, concurrent-write safety, permission bits, PKCE/state round-trips, and env-shadow detection are the kind of thing we want covered before behaviour depends on them. This section touches security surfaces; route through `compound-engineering:review:security-sentinel` before merge.
+
+  Patterns to follow: `internal/megamcp/auth.go` for the placeholder-substitution read path. Keep env-var-as-truth semantics intact.
+
+  Test scenarios:
+  - Happy path: `auth link github` completes OAuth round-trip against a stubbed authorization server with state + PKCE verification and writes the token to the store with 0600 perms.
+  - Happy path: after `auth link`, a printed CLI that does not have its env var set reads its token via `HydrateForRequest` and successfully calls the API; token never appears in process env.
+  - Happy path: `auth list --json` reports `shadowed_by_env: true` when an env var is set and `env_differs: true` when its value does not match the store.
+  - Edge case: store file does not exist; `auth status` reports empty cleanly.
+  - Edge case: env var is set AND store has a different value; env var wins but `auth doctor` yellow-flags the divergence with the originating shell file.
+  - Edge case: store file has wrong perms; `auth doctor` reports a red finding and `auth fix-perms` restores 0600 and 0700 on the parent dir.
+  - Edge case: callback with missing `state` or `state` mismatch returns exit 4 without corrupting the store.
+  - Edge case: callback with `code` but token exchange returns 5xx returns exit 5 with a retry hint.
+  - Edge case: OAuth flow where provider does not support PKCE returns a named warning during `auth link` and refuses to proceed unless `--allow-no-pkce` is passed; records the downgrade in the entry metadata.
+  - Edge case: concurrent `auth link` invocations serialise via the lock file; the second call reports "another auth operation in progress" and exits 5.
+  - Edge case: api-key prompt rejects an argv-supplied `--token` value with an actionable error.
+  - Edge case: tampered `credentials.json` (HMAC mismatch) returns exit 4 on read with a "credentials file integrity failure; re-run auth link" message.
+  - Error path: OAuth callback with `error=access_denied` returns exit 4 and does not corrupt the store.
+  - Error path: token refresh returns 401; `auth doctor` marks the creds expired, does not crash.
+  - Error path: loopback callback binds successfully but no request arrives within 120s; returns exit 4 and shuts down the listener.
+  - Integration: three printed CLIs share one store; each sees only its own slug's token via `HydrateForRequest`; none of them see any slug's token in `os.Environ()`.
+  - Integration: printed CLI using `SafeExec` to invoke `curl` does not leak `*_TOKEN` to the child process's environment.
+
+  Verification: on a clean machine, `printing-press auth link github && github-pp-cli issues list` succeeds without the user ever exporting `GITHUB_TOKEN`, and `ps eww` during the call does not show the token in the printed CLI's environment.
+
+- [ ] Unit 4: `allowed_tools` and `denied_tools` on per-API MCP and megamcp
+
+  Goal: Ship `--allow` and `--deny` flags on printed MCP servers and the aggregate server.
+
+  Requirements: R3
+
+  Dependencies: None. Can ship in parallel with Units 1-3.
+
+  Files:
+  - Modify: megamcp's `internal/megamcp/activation.go` and `metatools.go` to accept allow/deny lists (slug-scoped like `espn:scores_get`).
+  - Modify: generator template for `cmd/<api>-mcp/main.go` to accept `--allow` / `--deny` flags.
+  - Test: `internal/megamcp/activation_test.go` (extend), plus a new template-output snapshot test.
+
+  Approach:
+  - Both lists are comma-separated. Deny wins over allow. Empty allow means "all except denied".
+  - megamcp additionally accepts `--allow-api` / `--deny-api` for whole-API gating.
+  - Scorer recognises the new flag as a ship-ready feature so we do not penalise CLIs that now expose it.
+
+  Execution note: Extend the existing activation tests rather than adding a parallel test file; the behaviour is a filter layered on top of activation.
+
+  Test scenarios:
+  - Happy path: `--allow espn:scores_get` exposes only `scores_get`.
+  - Happy path: `--deny espn:scores_delete` hides one tool; others remain.
+  - Edge case: both `--allow` and `--deny` set; deny wins over allow conflicts.
+  - Edge case: unknown tool name in `--allow` logs a warning, does not crash.
+  - Integration: megamcp with `--allow-api espn,dub` only surfaces those two manifests in `library_info`.
+
+  Verification: `mcp list-tools` on a filtered server returns exactly the expected set across three configurations.
+
+- [ ] Unit 5: Trigger daemon (polling mode)
+
+  Goal: Ship `printing-press trigger add`, `trigger list`, `trigger run`, `trigger logs`, `trigger remove` with polling-based delivery.
+
+  Requirements: R3, R4
+
+  Dependencies: Unit 1 (registry), Unit 3 (auth resolution).
+
+  Files:
+  - Create: `internal/triggers/store.go`, `internal/triggers/runner.go`, `internal/triggers/sinks.go`
+  - Create: `internal/cli/trigger_cmd.go`
+  - Modify: printed-CLI template spec so every sync-capable manifest declares its "trigger-ready" resources in `tools-manifest.json` (additive field, backward compatible).
+  - Test: `internal/triggers/runner_test.go`, `internal/triggers/sinks_test.go`
+
+  Approach:
+  - `trigger add <api> <event> --sink --every` validates the event exists in the API's manifest, writes a trigger record to `~/.pp/triggers.json` (0600, 0700 parent), does not start a daemon.
+  - `trigger run` reads the file and schedules polls using `time.Ticker`. For each trigger it shells out (via `SafeExec` so tokens do not leak into child env) to the printed CLI's incremental sync or list-since command and diffs.
+  - Daemon auth: uses `authstore.HydrateForRequest` at request time only (KTD-13, Unit 3). Tokens never land in the daemon's process env because the daemon is long-lived and `/proc/<pid>/environ` is readable same-UID.
+  - Sinks:
+    - `webhook`: signed POST over HTTPS. Plain `http://` is refused unless `--insecure` is explicitly passed. Signing is HMAC-SHA256 over the canonical raw body bytes (before any JSON re-serialization). Header: `X-PP-Signature: t=<unix-seconds>,v1=<hex-hmac>`. Receivers reject signatures with a timestamp older than 300 seconds (replay window). Each delivery also carries `X-PP-Delivery-Id: <uuidv4>` for receiver-side idempotency.
+    - `stdout`: writes JSONL for an agent to tail.
+    - `file`: appends with rotation at 10MB.
+  - Secret origin and rotation:
+    - On first `trigger add --sink webhook`, auto-generate a 32-byte random secret, print it exactly once with a "save this now" warning, and store it in `~/.pp/triggers.json` under the sink config (0600).
+    - `trigger rotate-secret <id>` generates a new secret and accepts both old and new signatures for an overlap window (default 10 minutes, configurable via `--overlap`). During the window, each outbound request carries both `X-PP-Signature` (v1=new) and `X-PP-Signature-Prev` (v1=old) so receivers can cut over with zero missed deliveries.
+    - A user-supplied `--secret` flag on `trigger add` is supported for receivers that already have a secret from another system.
+    - A missing secret refuses to start the daemon with exit 2 and a named remediation; there is no silent unsigned-send path.
+  - Delivery semantics: at-least-once; receiver-side dedup expected via `X-PP-Delivery-Id`. Failed deliveries retry with exponential backoff up to 6 attempts over ~30 minutes, then queue to disk with bounded depth (default 1000 events per trigger).
+  - `trigger logs` reads the rotated log.
+
+  Execution note: Start with a failing integration test that runs `trigger add` + `trigger run` against a mock HTTP server with seeded data, and asserts the webhook was called with the expected diff.
+
+  Patterns to follow: `sync` cursor handling in printed CLIs. Reuse the cursor format wherever possible so a trigger and an ad-hoc `sync` do not fight each other.
+
+  Test scenarios:
+  - Happy path: new Linear issue appears; trigger fires; webhook receives HTTPS POST with `X-PP-Signature` (HMAC-SHA256 verifies against stored secret), `X-PP-Delivery-Id` (UUIDv4), and body with `event: linear.issue_created`.
+  - Happy path: `trigger list --json` returns every configured trigger with next-poll time.
+  - Happy path: `trigger rotate-secret <id> --overlap 10m` emits both `X-PP-Signature` and `X-PP-Signature-Prev` for 10 minutes, then drops the old secret.
+  - Edge case: API returns the same cursor twice; no duplicate events emitted (daemon-side idempotency).
+  - Edge case: receiver is reachable but returns 5xx; event is queued to disk and retried with exponential backoff up to 6 attempts.
+  - Edge case: `--sink webhook --url http://example` without `--insecure` is refused at `trigger add` with exit 2.
+  - Edge case: request with timestamp older than 300s replay window is correctly rejected by a verifying receiver (test both sides).
+  - Edge case: missing webhook secret refuses to start the daemon with exit 2 and a named remediation.
+  - Error path: the printed CLI is not installed; `trigger add` fails with exit 3 and points to `printing-press run list` to see what is installed.
+  - Error path: polled API returns 429; runner respects retry-after and extends the tick for that trigger only.
+  - Error path: daemon process crash mid-delivery; on restart the undelivered event replays exactly-once per `X-PP-Delivery-Id` (at-least-once delivery, receiver-side dedup expected).
+  - Integration: two triggers against the same API share one HTTP connection pool and do not exceed the API's rate limit budget.
+  - Integration: long-running daemon with 24h uptime shows no authentication token in `/proc/<pid>/environ` (HydrateForRequest confirmed).
+
+  Verification: 24-hour soak with three triggers against Linear, GitHub, and HubSpot printed CLIs shows zero missed events, signed deliveries verify on the receiver, and `ps eww` on the daemon shows no tokens in env for the entire run.
+
+- [ ] Unit 6: `execute --parallel` and megamcp `batch_execute`
+
+  Goal: Concurrent execution across tools.
+
+  Requirements: R3
+
+  Dependencies: Unit 2.
+
+  Files:
+  - Modify: `internal/supercli/execute.go` to accept `--parallel` when input is a batch array.
+  - Modify: `internal/megamcp/metatools.go` to register `batch_execute`.
+  - Test: `internal/supercli/execute_test.go` (extend), `internal/megamcp/metatools_test.go` (extend)
+
+  Approach:
+  - Bounded concurrency via `cliutil.FanoutRun` (already exists). Default fanout of 8. Per-API rate-limit aware: each API gets its own semaphore derived from manifest rate-limit metadata if present, else defaults.
+  - Results returned in input order regardless of completion order.
+  - Any one failure does not abort the batch; the response array contains typed errors per index.
+
+  Patterns to follow: `cliutil.FanoutRun` (see glossary).
+
+  Test scenarios:
+  - Happy path: batch of three tools across three APIs returns three results in input order.
+  - Edge case: one of three fails; result at that index is a typed error, other two succeed.
+  - Edge case: all three target the same API; per-API semaphore prevents flooding.
+  - Integration: batch with 20 tools across 4 APIs completes in under 2x the slowest single call.
+
+  Verification: load test with 100-tool batch against mock servers shows linear-ish concurrency scaling up to the configured fanout cap.
+
+## System-Wide Impact
+
+- Interaction graph: the super-CLI and megamcp converge on one shared `internal/apihttp` package for request building and auth resolution; both depend on `internal/authstore` going forward. Changes to either ripple to both.
+- Error propagation: typed exits (0/2/3/4/5/7) are preserved across the super-CLI, meaning agents do not need new error-handling code to consume `execute`/`proxy` alongside existing printed CLIs.
+- State lifecycle risks: the credential store and the trigger store both live at `~/.pp/`. Concurrent writers (two `printing-press auth link` invocations) need atomic-write discipline. Same applies to `~/.pp/triggers.json` under `trigger add` contention.
+- API surface parity: every new subcommand needs a parallel MCP meta-tool in megamcp so agents that prefer MCP do not lose reach. `list` -> `library_info` (exists), `search` -> `search_tools` (exists), `execute` -> `batch_execute` (new), `auth` ops -> `auth_status`/`auth_link_url` meta-tools (new, link flow is interactive so the MCP version returns the URL and expects the user to complete in-browser).
+- Integration coverage: the store file is accessed by both the super-CLI and every printed CLI via `authload.Hydrate`. Unit tests covering single-process behaviour are necessary but not sufficient; we need one cross-process integration test that exercises "super-CLI writes, printed CLI reads".
+- Unchanged invariants: printed CLIs keep reading env vars as their primary credential source. Absence of the unified store does not break them. Generators keep producing the same two binaries per API. The scorecard bar stays where it is.
+
+## Risks & Dependencies
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| OAuth providers differ enough per API that `authproviders/` turns into a maintenance tax | Med | Med | Start with three providers (GitHub, Linear, Slack). Require each new provider to fit a 150-line budget; if it does not, capture in retro and reshape the interface before the fourth |
+| Unified auth store becomes a secret-exfiltration target (plaintext JSON) | Med | High | 0600 perms on the file, feature-flag the keychain backend and complete it in a P2 follow-up before promoting the store to default |
+| Trigger daemon drifts from printed CLI sync cursors and double-fires | Med | Med | Share the cursor source of truth rather than duplicating it; test with the same DB that the printed CLI writes to |
+| Super-CLI grows into a monolith that couples to every printed CLI's internals | Med | Med | The boundary is the manifest. Super-CLI reads manifests and uses the generic HTTP handler. If a printed CLI has behaviour the super-CLI cannot reach, that is a signal the manifest needs to be richer, not that the super-CLI needs a shortcut |
+| We ship an `auth` story that feels worse than Composio's because we lack a hosted OAuth broker | High | Med | Set expectations: we will not be cloning Composio's 1,000-toolkit OAuth coverage. We ship the interface and the top five providers, and document how to add the sixth. Positioning emphasises local-first as the feature |
+| megamcp allowed_tools filter collides with activation logic | Low | Med | Extend existing activation tests rather than forking a new code path |
+| Parallel execution breaks fragile APIs without per-API rate budgets | Med | Med | Default fanout of 8; per-API semaphores read from manifest metadata; first-class retry-after handling |
+| Agents get confused by two ways to do the same thing (`<api>-pp-cli X` vs `printing-press run execute api X`), especially since they have different semantics (KTD-9) | Med | Med | Documentation emphasises `run execute` as raw-HTTP-through-manifest, printed CLIs as the full-local-layer path. Help text on both surfaces names the other and states the scope boundary. Scorer adds a dimension for "does the help text point to the sibling surface" |
+| Manifest schema drift between generator and runtime versions causes silent-fail on new fields | Med | High | Unit 0 blocks; `schema_version` field and reader gate enforce the contract before any additive field ships |
+| Secrets exfiltration via child-process env inheritance, core dumps, or `/proc/<pid>/environ` | Med | High | `HydrateForRequest` keeps tokens out of process env; `SafeExec` strips secret env vars from subprocesses; trigger daemon mandated to use `HydrateForRequest`; file-based store documented as a same-UID risk until keychain P2 ships |
+| OAuth loopback flow missing state/PKCE/exact-URI hardening | Med | High | Explicitly specified in Unit 3 Approach; PKCE is default-on; state verification is mandatory; providers without PKCE require an explicit downgrade flag and record the downgrade in entry metadata |
+| Webhook signature scheme is homegrown or ambiguous | Med | Med | Unit 5 commits to HMAC-SHA256 + `X-PP-Signature` + timestamp replay window + `X-PP-Delivery-Id`; rotation primitive with overlap window; refuses plain HTTP without `--insecure` |
+
+## Future Considerations (B and C tier)
+
+Not in this plan. Record decisions here so we do not relitigate.
+
+- B-tier candidate: `printing-press run --file workflow.yaml`. Revisit when we see a concrete user case we cannot serve with `ppl` skills plus `execute --parallel`.
+- B-tier candidate: `printing-press generate ts|py` typed SDK codegen. Revisit when we have a frontend agent customer.
+- B-tier candidate: per-CLI `llms.txt`. Cheap. Bundle it opportunistically when we next touch the README generator.
+- C-tier refused: hosted dashboard, multi-tenant per-user MCP URL hosting, Python sandbox, white-label auth screens. These require a backend or a runtime we do not want to own.
+
+## Sources & References
+
+- CLI reference: https://composio.dev/toolkits/linkedin/framework/cli
+- Platform docs: https://docs.composio.dev
+- MCP overview: https://docs.composio.dev/docs/mcp-overview
+- Triggers: https://docs.composio.dev/docs/triggers
+- Claude Code skills: https://github.com/ComposioHQ/skills
+- Composio monorepo: https://github.com/ComposioHQ/composio
+- PP megamcp plan (shipped): `docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md`
+- PP megamcp implementation: `internal/megamcp/`
+- PP README (feature inventory): `README.md`
+- PP AGENTS.md (machine-vs-printed discipline, glossary): `AGENTS.md`
+- Library registry: `~/printing-press-library/registry.json`
diff --git a/docs/plans/2026-04-19-002-feat-super-cli-run-namespace-plan.md b/docs/plans/2026-04-19-002-feat-super-cli-run-namespace-plan.md
new file mode 100644
index 00000000..26730781
--- /dev/null
+++ b/docs/plans/2026-04-19-002-feat-super-cli-run-namespace-plan.md
@@ -0,0 +1,395 @@
+---
+title: "feat(cli): super-CLI run namespace for cross-API execute, search, info, and proxy"
+type: feat
+status: active
+date: 2026-04-19
+---
+
+# feat(cli): super-CLI run namespace for cross-API execute, search, info, and proxy
+
+## Overview
+
+Add a `printing-press run` subcommand group that gives agents and humans one front door to every printed CLI the user has installed. The surface mirrors the MCP-side capabilities megamcp already provides (library_info, activate_api, search_tools) but as Cobra subcommands on the existing printing-press binary. It does not replace printed CLIs; it sits beside them as a discovery, batching, and raw-HTTP surface for cross-API work.
+
+The plan has one blocking precondition (manifest schema versioning) and five implementation units. It depends on the unified auth plan (`2026-04-19-003-feat-unified-auth-manager-plan.md`) for `run execute` and `run proxy` but not for `run list`, `run info`, or `run search`. Those three can ship independently.
+
+## Problem Frame
+
+The Printing Press generates one printed CLI per API plus a matching per-API MCP server. The library ships 21 printed CLIs today. Agents that need cross-API behaviour must currently:
+
+1. Know every binary name (`espn-pp-cli`, `dub-pp-cli`, `linear-pp-cli`, etc).
+2. Shell out to each one separately, threading auth via per-API env vars.
+3. Re-discover each CLI's command vocabulary from its own `--help`.
+
+The MCP side already solves this via megamcp: one aggregate server with `library_info`, `activate_api`, `deactivate_api`, `search_tools`, `setup_guide`. The CLI side has no equivalent. The same manifest data megamcp uses can drive a CLI-side super-surface. This plan delivers that.
+
+The PP philosophy (README, "Absorb and Transcend") says the GOAT CLI is built by absorbing every good idea and compounding on top. Cross-API single-entry invocation is table-stakes in the broader ecosystem and the absence of a CLI-side version is a visible gap for any agent that composes across APIs.
+
+## Requirements Trace
+
+- R1. Expose one CLI entry point (`printing-press run ...`) that can list installed printed CLIs, show tool schemas, search tools across APIs, execute a tool, and make authenticated raw HTTP calls.
+- R2. Read from the same `tools-manifest.json` source megamcp reads, so MCP and CLI stay in lockstep.
+- R3. Preserve PP's agent-native behaviours: auto-JSON when piped, typed exits (0/2/3/4/5/7), actionable errors, `--dry-run`, `--get-schema`.
+- R4. Do not duplicate the local data-layer behaviour of printed CLIs. Super-CLI is raw-HTTP; printed CLIs remain the opinionated product surface with SQLite, FTS5, compound commands, and domain archetypes.
+- R5. Ship manifest schema versioning before any new additive field so runtime and generator versions can drift safely.
+
+## Scope Boundaries
+
+In scope:
+- `printing-press run list`, `run info`, `run search`, `run execute`, `run proxy`.
+- `internal/apihttp/` shared package extracted from megamcp's handler.
+- `tools-manifest.json` `schema_version` field and reader gate.
+- Parity tests between `run execute` and megamcp's `MakeToolHandler` output for raw HTTP response.
+
+Out of scope:
+- Local data-layer routing. `run execute` does not touch printed-CLI SQLite stores, compound commands, FTS5, or `--select`/`--compact` semantics. Users wanting those keep using `<api>-pp-cli` directly.
+- Authentication flows. Covered by `2026-04-19-003-feat-unified-auth-manager-plan.md`. `run execute` and `run proxy` consume the auth store; they do not create it.
+- Trigger daemon, `--allow`/`--deny` MCP filtering, parallel-batch semantics beyond a thin `--parallel` shim. Those are separate plans.
+- A separate `pp` binary. This is a subcommand group on the existing `printing-press` binary.
+
+## Context & Research
+
+### Relevant code and patterns
+
+- `internal/megamcp/handler.go` - `MakeToolHandler` is the existing request-builder. Path-param substitution, body routing, header handling, host allowlist, auth gate, response classification, and 32KB agent truncation all live here. This is the extraction target for `internal/apihttp/`.
+- `internal/megamcp/manifest.go` - `ToolsManifest` and loader. Schema versioning lands here.
+- `internal/megamcp/registry.go` - multi-manifest loader. `run list` and `run search` read through it.
+- `internal/megamcp/metatools.go` - the shape of `library_info`, `search_tools`, `setup_guide`. `run list`, `run search`, `run info` mirror these shapes on the CLI side.
+- `internal/megamcp/auth.go` - `BuildAuthHeader`, `ApplyAuthFormat`, `hasAuthConfigured`. Used unchanged by `apihttp`.
+- `internal/cli/` - existing Cobra wiring for `generate`, `verify`, `scorecard`, etc. New `run` subcommand group lands beside them.
+- `internal/cliutil/` - generator-reserved helpers (`FanoutRun`, `CleanText`). Do not put super-CLI code here; `internal/supercli/` is its own namespace.
+- `AGENTS.md` glossary - "the printing-press binary" vs "printed CLI" distinction. The super-CLI is part of the printing-press binary (machine role), operating on printed CLIs by reading their manifests.
+
+### Institutional learnings
+
+- `AGENTS.md` machine-vs-printed rule: changes to the super-CLI are machine changes; they affect every installed printed CLI but do not alter any printed CLI's on-disk code.
+- README "Dual interface from one spec" and "Absorb and Transcend" framing: one spec produces CLI + MCP; the super-CLI lets that same manifest drive cross-API execution without duplicating logic.
+- Existing megamcp plan (`docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md`, shipped) established the manifest-as-contract model that this plan extends to the CLI side.
+
+### External references
+
+- `internal/megamcp/` test suite as the ground truth for expected handler behaviour across 21 live manifests in the library.
+- No external library research needed. The super-CLI is a thin reuse of existing in-repo infrastructure.
+
+## Key Technical Decisions
+
+KTD-1. The super-CLI ships as subcommands on the existing `printing-press` binary, not a new binary. Rationale: one install path, one release train, one `/install mvanhorn/cli-printing-press` gets both generator and runtime surfaces. A separate binary doubles distribution concerns without clear upside.
+
+KTD-2. Super-CLI verbs live under a dedicated `printing-press run` namespace. Rationale: `list`, `search`, `info` are high-value English verbs that future generator features (catalog search, spec info) will want. Grouping runtime-role commands under `run` reserves the top-level namespace for generator-role commands.
+
+KTD-3. `run execute` is raw-HTTP-through-manifest. It does NOT invoke local SQLite, compound commands (`stale`, `orphans`, `load`, `reconcile`), domain archetypes, FTS5 search, or `--select`/`--compact` projections. Rationale: without this boundary, `run execute <api> <tool>` silently diverges from `<api>-pp-cli <tool>` for the same tool name, and the user cannot reason about which call gives which result. Help text on both surfaces names the other and states the scope boundary. The scorer adds a dimension for "does help text point to the sibling surface" so drift is caught before ship.
+
+KTD-4. A shared `internal/apihttp/` package returns a transport-neutral `Response` type (`{StatusCode, Body, Headers}`), not `mcp.CallToolResult`. MCP-side wraps it and applies the 32KB `maxAgentResponse` truncation in that wrapper. CLI-side maps status codes to typed exits (0/2/3/4/5/7) in its own wrapper. Rationale: without the split, the shared package grows cli-mode flag branches and the 32KB agent cap silently applies to CLI output. Host validation and credential redaction stay in `apihttp` because both callers need them.
+
+KTD-5. Manifest schema versioning is a blocking precondition. `tools-manifest.json` gets a `schema_version: int` field starting at 1. The reader refuses unknown major versions with an actionable message and accepts unknown minor-version fields with a debug log. Rationale: KTD-4's extraction and future additive fields make every manifest write-then-read a backward-compat contract between two release trains (generator releases vs regenerated library manifests). Without an explicit gate, every new field becomes a silent-fail surface.
+
+KTD-6. `run execute` auth resolution delegates to the `internal/authstore` + `cliutil/authload.HydrateForRequest` pattern defined in the unified auth plan. Env-var-wins precedence is preserved. When the auth plan has not yet shipped, `run execute` still works for APIs whose env vars are set directly, and surfaces a named "credentials not found" exit-4 error with a pointer to `printing-press auth link <api>` (which is planned but not yet present). Rationale: decoupling sequencing so read-only `run list`, `run info`, `run search` can ship in Unit 1 without waiting for auth.
+
+## Open Questions
+
+### Resolved During Planning
+
+- Q: Ship as a new `pp` binary or as subcommands on `printing-press`? A: Subcommands on `printing-press`. See KTD-1.
+- Q: Reserve top-level verbs (`list`, `search`, `info`) or namespace them? A: Namespace under `run`. See KTD-2.
+- Q: Does `run execute` read printed-CLI SQLite when installed? A: No. KTD-3.
+- Q: Does the extracted package return MCP types or neutral types? A: Neutral. KTD-4.
+- Q: Is manifest versioning a precondition or a later addition? A: Precondition. KTD-5.
+
+### Deferred to Implementation
+
+- Exact column order and width heuristics for the `run list` human-friendly table. Resolve once the first implementation hits real terminal widths.
+- Ranking function inside `run search`. Start with the existing `search_tools` ranking in megamcp and adjust only if CLI callers find it inadequate.
+- Whether `run proxy` supports streaming response bodies. Default is buffered; streaming is a P2 follow-up if a printed CLI surfaces a need (e.g., large CSV export endpoints).
+
+## 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.
+
+### Command surface
+
+```
+printing-press                       # existing generator entry point, unchanged
+  generate ...
+  verify ...
+  scorecard ...
+  mega-mcp ...
+
+  run list                           # installed printed CLIs + auth status
+  run info <api> [<tool>]            # manifest or tool-schema lookup
+  run search <query> [--api X]       # delegates to manifest registry search
+  run execute <api> <tool> [-d ...]  # raw-HTTP-through-manifest (KTD-3)
+      [--parallel -d @batch.json]
+      [--dry-run] [--get-schema] [--skip-auth-check]
+  run proxy --api X -X METHOD /path  # authenticated raw HTTP
+      [-H ...] [-d ...]
+```
+
+### Package boundary
+
+```
+internal/apihttp/                    # new, shared
+  Execute(ctx, manifest, tool, args) -> (Response, error)
+  Response = { StatusCode, Body, Headers }
+  host allowlist, credential redaction, path-param substitution
+
+internal/megamcp/handler.go          # existing, refactored
+  wraps apihttp.Execute
+  applies 32KB maxAgentResponse truncation here, not in apihttp
+  shapes mcp.CallToolResult
+
+internal/supercli/                   # new, CLI wrappers
+  list.go, info.go, search.go, execute.go, proxy.go, batch.go
+  maps apihttp Response to typed exits (0/2/3/4/5/7)
+  auto-JSON when piped; human table in TTY
+```
+
+### Manifest versioning gate
+
+```
+when reader encounters manifest:
+  missing schema_version  -> treat as 1
+  schema_version = 1 + known fields -> load
+  schema_version = 1 + unknown field -> load, debug-log the skip
+  schema_version > 1      -> refuse with upgrade instruction; do not register
+```
+
+## Implementation Units
+
+- [ ] Unit 0: Manifest schema versioning (blocking precondition)
+
+  Goal: Add `schema_version` to `tools-manifest.json`, enforce it in the manifest reader, and establish the backward-compat contract before any new field ships.
+
+  Requirements: R2, R5
+
+  Dependencies: None. Must land before Units 1-5.
+
+  Files:
+  - Modify: `internal/megamcp/manifest.go` to add `SchemaVersion int` on `ToolsManifest` and refuse unknown major versions.
+  - Modify: `internal/pipeline/toolsmanifest.go` to emit `schema_version: 1` on generation.
+  - Modify: `printing-press-library/registry.json` tolerance for mixed-version manifests.
+  - Test: `internal/megamcp/manifest_test.go` extended with version-missing, version-current, version-future-major, version-future-minor cases.
+
+  Approach:
+  - Current version is 1. Missing field is treated as 1 so all shipped manifests remain valid.
+  - Future-major refusal is actionable: "manifest schema_version=2 is newer than this printing-press build; upgrade to at least vX.Y."
+  - Future-minor unknown-field handling accepts with a debug-level log and never fails. Forward-compat minor bumps must be free.
+
+  Patterns to follow: existing `ToolsManifest` struct and manifest tests.
+
+  Test scenarios:
+  - Happy path: manifest without `schema_version` loads as v1.
+  - Happy path: manifest with `schema_version: 1` and known fields loads.
+  - Edge case: manifest with `schema_version: 1` and an unknown forward-compat field loads with one debug log and no error.
+  - Error path: manifest with `schema_version: 2` returns a typed error naming the minimum required build; no partial registration.
+  - Integration: megamcp started against a library containing one v1 manifest and one v2 manifest registers only the v1 one and surfaces the skip distinctly.
+
+  Verification: `printing-press run list` (Unit 1) shows accepted and skipped manifests so users can diagnose version mismatches without reading logs.
+
+- [ ] Unit 1: Super-CLI read surface - run list, run info, run search
+
+  Goal: Ship the three read-only subcommands. Zero dependency on the auth plan; can ship immediately after Unit 0.
+
+  Requirements: R1, R2, R3
+
+  Dependencies: Unit 0.
+
+  Files:
+  - Create: `internal/supercli/registry.go` wrapping `internal/megamcp/registry.go`.
+  - Create: `internal/supercli/list.go`, `info.go`, `search.go`.
+  - Create: `internal/cli/supercli_cmd.go` for Cobra wiring under `run`.
+  - Modify: `cmd/printing-press/main.go` to register the `run` group.
+  - Test: `internal/supercli/registry_test.go`, `list_test.go`, `info_test.go`, `search_test.go`.
+
+  Approach:
+  - `run list` output: table in TTY, JSON when piped. Columns: api, cli-binary, mcp-binary, tool_count, auth_type, auth_status. `auth_status` is best-effort against env vars only in Unit 1; it is refined in Unit 3 once auth store is live.
+  - `run info <api>` returns the full manifest. `run info <api> <tool>` returns a single tool schema.
+  - `run search <query>` reuses megamcp's `makeSearchToolsHandler` ranking. Result shape matches what the MCP meta-tool returns so agents can move between surfaces without reshaping payloads.
+
+  Patterns to follow: `internal/megamcp/metatools.go` handlers.
+
+  Test scenarios:
+  - Happy path: `run list` returns all 21 library manifests when the library is installed.
+  - Happy path: `run info espn` returns the ESPN manifest.
+  - Happy path: `run info espn scores_get` returns the single tool schema.
+  - Happy path: `run search scores` ranks `espn:scores_get` above unrelated tools.
+  - Edge case: `run info unknown-api` returns exit 3 with a "did you mean" suggestion list.
+  - Edge case: empty library returns `[]` in JSON mode with exit 0, not an error.
+  - Edge case: manifest with `schema_version: 2` is listed as skipped in `run list --all` and omitted by default.
+  - Integration: `run search --api espn scores` filters to ESPN only; same ranking as the unfiltered search when restricted to ESPN's tool set.
+
+  Verification: `run list`, `run info`, `run search` all pass `--json | jq` round-trips. Auto-JSON triggers when piped. Human table output fits 120-column terminals for the 21-library baseline.
+
+- [ ] Unit 2: Shared `internal/apihttp/` extraction
+
+  Goal: Lift the request-building, auth-header, host-allowlist, path-param, and response-classification logic from `internal/megamcp/handler.go` into a transport-neutral `internal/apihttp/` package. Megamcp becomes a thin wrapper; the super-CLI becomes the second caller in Unit 3.
+
+  Requirements: R2, R4
+
+  Dependencies: Unit 0.
+
+  Files:
+  - Create: `internal/apihttp/execute.go` with `Execute(ctx, manifest, tool, args) -> (Response, error)` and the `Response` type.
+  - Create: `internal/apihttp/hosts.go` for host allowlist logic lifted from handler.go.
+  - Create: `internal/apihttp/redact.go` for credential redaction.
+  - Modify: `internal/megamcp/handler.go` to call `apihttp.Execute`, then apply 32KB truncation and shape `mcp.CallToolResult`.
+  - Test: `internal/apihttp/execute_test.go`, `hosts_test.go`, `redact_test.go`.
+  - Modify: `internal/megamcp/handler_test.go` to prove parity pre and post extraction.
+
+  Approach:
+  - Characterization-first. Snapshot the current handler behaviour with a fixture suite covering all 21 manifests, then extract, then re-run the snapshot suite to prove parity.
+  - `Response` is a thin struct: status code, body bytes, response headers. No MCP types. No truncation.
+  - Host allowlist continues to enforce the manifest's declared base URL host; no wildcard scheme changes.
+  - Credential redaction stays keyed on the same auth-format tokens megamcp uses.
+
+  Execution note: characterization-first. Write the pre-extraction snapshot suite first and hold it fixed across the refactor. Add the CLI-side callers in Unit 3 only after snapshots are green.
+
+  Patterns to follow: `internal/megamcp/handler.go`, `internal/megamcp/auth.go`.
+
+  Test scenarios:
+  - Happy path: request construction for a GET with path params produces byte-identical output pre and post extraction across all 21 manifests.
+  - Happy path: POST with body routing produces byte-identical output.
+  - Happy path: auth header is applied identically (same header name, same value, same format token expansion).
+  - Edge case: unsubstituted path placeholder returns the same typed error pre and post.
+  - Edge case: response body larger than 10MB is still rejected at the wire by `apihttp`; the 32KB agent cap lives only in the megamcp wrapper and does not apply via `apihttp` directly.
+  - Error path: host not in allowlist returns the same error on both sides.
+  - Integration: existing megamcp handler_test.go suite passes without modification after the refactor.
+
+  Verification: handler_test.go passes pre and post extraction with no assertion changes. `apihttp` is importable from `supercli` in Unit 3 without circular dependency.
+
+- [ ] Unit 3: run execute + run proxy
+
+  Goal: Ship `printing-press run execute <api> <tool>` and `run proxy --api X -X METHOD /path` on top of `apihttp`, with typed exits, auto-JSON, and auth resolution through the unified auth store.
+
+  Requirements: R1, R3, R4
+
+  Dependencies: Unit 0, Unit 1, Unit 2, and the unified auth plan's Unit 3 (authstore) for credentials.
+
+  Files:
+  - Create: `internal/supercli/execute.go`, `proxy.go`, `batch.go`.
+  - Modify: `internal/cli/supercli_cmd.go` to wire `run execute`, `run proxy`.
+  - Test: `internal/supercli/execute_test.go`, `proxy_test.go`, `batch_test.go`.
+
+  Approach:
+  - `run execute` resolves `<api>` via the registry, resolves `<tool>` against manifest tools, calls `apihttp.Execute`, and maps the status code to a typed exit: 2xx -> 0, 401/403 -> 4, 404 -> 3, 429 -> 7 (after one retry-after backoff), 4xx-other -> 2, 5xx -> 5.
+  - Human output: pretty-printed response body in TTY. JSON when piped. No truncation cap; users can pipe to `head` if they want.
+  - `run proxy` skips the tool registry, resolves auth for the named API, and issues a raw request. Curl-shaped flags: `-X`, `-H` (repeatable), `-d`.
+  - Both honour `--dry-run` (print the request, no send), `--get-schema` (print the tool schema for `execute`; for `proxy` returns an error since there is no schema).
+  - `--skip-auth-check` allows the caller to hit an unauthenticated endpoint without a store lookup.
+  - Help text on both commands names `<api>-pp-cli` as the full-product alternative and explicitly states "this command does not use the local data layer" (KTD-3).
+
+  Patterns to follow: megamcp handler wiring, `internal/cliutil/FanoutRun` for the optional `--parallel` batch shim.
+
+  Test scenarios:
+  - Happy path: `run execute espn scores_get --league nfl` returns valid JSON with exit 0.
+  - Happy path: `run proxy --api dub -X GET /links` returns the raw API response body; no local-store augmentation.
+  - Happy path: megamcp wrapper around `apihttp.Execute` still truncates at 32KB; `run execute` does not.
+  - Edge case: missing required path param returns exit 2 and names the missing placeholder.
+  - Edge case: `--skip-auth-check` lets the caller hit an unauthenticated endpoint without a store lookup.
+  - Edge case: `--dry-run` prints the fully-resolved request (URL, headers with redacted auth, body) without sending.
+  - Error path: 401 returns exit 4 with "run `printing-press auth link <api>`" hint.
+  - Error path: 404 returns exit 3 with the attempted path.
+  - Error path: 429 with retry-after returns exit 7 after exactly one backoff attempt.
+  - Error path: host not in manifest allowlist returns exit 2 with "host not permitted by manifest".
+  - Integration: `run execute --parallel -d @batch.json` runs three tool calls concurrently, returns responses in input order, and a single failure does not abort the batch.
+  - Integration: parity on raw HTTP status and body between `run execute espn X` and `espn-pp-cli X --json` for five tools (raw body only; KTD-3 scope).
+
+  Verification: parity matrix for five tools passes on the raw-HTTP subset defined in KTD-3. Megamcp snapshot suite continues to pass.
+
+- [ ] Unit 4: Scorer dimension for sibling-surface discoverability
+
+  Goal: Add one scorer dimension that checks whether a printed CLI's help text and its `--help` top-of-page reference the sibling surface (`printing-press run execute` for printed CLIs, and a pointer to `<api>-pp-cli` for the super-CLI help text).
+
+  Requirements: R4
+
+  Dependencies: Unit 3.
+
+  Files:
+  - Modify: `internal/generator/scorer.go` (or whichever file holds the Tier 1 dimension logic) to add the new check.
+  - Modify: generator templates for printed CLI root command help text to include the sibling pointer.
+  - Modify: super-CLI help text (`internal/cli/supercli_cmd.go`) to include the printed-CLI pointer.
+  - Test: scorer test fixtures updated; template snapshot tests updated.
+
+  Approach:
+  - Dimension is binary: help text either mentions the sibling surface or does not. Worth 2 points in Tier 1.
+  - Anti-gaming: the scorer checks for a specific sentence shape ("Use `<api>-pp-cli` when you want the local SQLite layer and compound commands") rather than a keyword, so printed CLIs cannot pass by sprinkling the word "run" into unrelated text.
+
+  Patterns to follow: existing Tier 1 dimension entries in the scorer.
+
+  Test scenarios:
+  - Happy path: a printed CLI whose template emits the sibling-pointer sentence scores the dimension.
+  - Edge case: a printed CLI whose help text was manually edited to remove the pointer fails the dimension on rescore.
+  - Integration: a re-scored library after this unit shows 21 CLIs all passing the new dimension (because the template change propagated through a regeneration pass).
+
+  Verification: re-running `printing-press scorecard` across the library shows the new dimension evaluated and the score delta is proportional to the number of CLIs that carry the sibling pointer.
+
+- [ ] Unit 5: Documentation updates
+
+  Goal: Update README, AGENTS.md, and the generator-emitted printed-CLI README template so that the super-CLI is discoverable and the KTD-3 boundary is explicit.
+
+  Requirements: R1, R4
+
+  Dependencies: Units 1-3.
+
+  Files:
+  - Modify: `README.md` to add a "Super-CLI" section between "Dual interface from one spec" and "Domain Archetypes".
+  - Modify: `AGENTS.md` glossary to add `run` namespace, `internal/apihttp/`, and a cross-reference between the super-CLI and printed CLIs.
+  - Modify: generator's README template so every printed CLI's README points to `printing-press run execute` as the cross-API entry point.
+  - Test: `internal/cli/release_test.go` `TestReadmeMentionsSuperCli` ensures future README edits do not drop the pointer.
+
+  Approach: documentation-only unit. Keep the tone consistent with existing README voice. Tables and fenced code blocks, no emphasis markers.
+
+  Test scenarios:
+  - Happy path: README renders with the new section present between the declared anchor headings.
+  - Edge case: generator-emitted README for a newly-printed CLI contains the sibling pointer.
+
+  Verification: a fresh generation pass on one catalog API emits a README mentioning `printing-press run` in the expected position.
+
+## System-Wide Impact
+
+- Interaction graph: megamcp, the super-CLI, and any future surface that needs to execute tools all converge on `internal/apihttp/`. A change to request-building ripples to both MCP and CLI callers uniformly.
+- Error propagation: typed exits (0/2/3/4/5/7) are preserved. Agents that already know PP's exit contract do not need new error-handling code to consume `run execute` alongside `<api>-pp-cli`.
+- State lifecycle risks: no new persistent state. The super-CLI is stateless and reads manifests plus (in Unit 3+) the auth store owned by the auth plan.
+- API surface parity: every `run` subcommand has an MCP meta-tool equivalent on megamcp today (`library_info`, `search_tools`, or the pending `batch_execute`). Keeping that parity is a non-goal per-feature but a guiding principle overall.
+- Integration coverage: the parity tests between `apihttp` pre and post extraction are the backbone. Without them the refactor is unsafe.
+- Unchanged invariants: printed CLIs keep reading env vars as their primary credential source. Printed CLI binaries are not renamed, their command vocabulary does not change, and their local data layers are untouched. The scorer bar does not move except for the one dimension added in Unit 4.
+
+## Risks & Dependencies
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| `apihttp` extraction regresses megamcp's handler behaviour in a subtle way | Med | High | Characterization-first in Unit 2; snapshot suite covers all 21 live manifests; extraction must keep the suite green |
+| Manifest schema drift between generator and runtime versions causes silent-fail on new fields | Med | High | Unit 0 blocks; `schema_version` field and reader gate enforce the contract |
+| Agents get confused by two ways to do "the same" thing with different semantics (`run execute` vs `<api>-pp-cli`) | Med | Med | Help text on both surfaces names the other; KTD-3 states the boundary; Unit 4 scorer dimension enforces it |
+| `run execute` is misused as a replacement for printed CLIs and users complain that local-layer features are missing | Med | Med | Help text explicit, README section explicit, error messages for missing compound commands point to the sibling surface |
+| Parity matrix between `run execute` and `<api>-pp-cli --json` produces false-positive bug reports every time a printed CLI ships a new compound command | Low | Med | KTD-3 scopes the matrix to raw-HTTP body parity only; compound commands and projection flags are excluded from the matrix |
+| `run proxy` is used to bypass manifest-declared host allowlist | Low | High | `apihttp` enforces the allowlist for `proxy` too; `proxy` accepts only paths against the named API's base URL, not arbitrary URLs |
+| Dependency on auth plan delays Unit 3 ship date | Med | Med | Units 0, 1, 2 ship independently and deliver visible value (list, info, search, `apihttp` refactor); Unit 3 waits on the auth plan's authstore unit |
+
+## Alternative Approaches Considered
+
+- A separate `pp` binary. Rejected in KTD-1: doubles distribution and release plumbing without a new capability.
+- Leaving `apihttp` inside megamcp and having the super-CLI shell out to the `<api>-pp-cli` binary for execute. Rejected: shelling out gives the local-layer path which KTD-3 explicitly rules out; raw-HTTP is what `run execute` must deliver.
+- Reusing megamcp's activation model on the CLI side (activate-before-execute). Rejected: activation was an MCP-specific response-size concession; CLI callers do not need it and it would add latency and state for no gain.
+- Reserving `list`, `search`, `info` at top level on the `printing-press` binary. Rejected in KTD-2: future generator-role commands will want those verbs.
+
+## Success Metrics
+
+- After Unit 1 ships, `printing-press run search <keyword>` returns cross-library results in under 200ms on a 21-manifest library with an FTS-free baseline.
+- After Unit 3 ships, agents can compose a batch across three APIs with a single `run execute --parallel -d @batch.json` call and receive responses in input order with per-entry typed exit codes.
+- Pre/post extraction of `apihttp` (Unit 2) shows zero assertion diffs in megamcp's handler test suite.
+- README and AGENTS.md reference the `run` namespace and the KTD-3 boundary within the release that ships Unit 3.
+
+## Documentation / Operational Notes
+
+- Changelog scope `cli` applies to every unit here. `Unit 4` also carries a `feat(cli):` prefix (scoring change is a user-visible behaviour change).
+- No runtime migration. No state migration. No new env vars introduced by this plan (auth env vars are the auth plan's concern).
+- Breaking changes: none. Every unit is additive.
+- Rollout: single binary release. No feature flag required. Manifest `schema_version: 1` lands in the first regenerated manifest and is retroactively tolerated as-if-1 for manifests already in the library.
+
+## Sources & References
+
+- Related code: `internal/megamcp/`, `internal/pipeline/toolsmanifest.go`, `internal/cli/`, `internal/generator/scorer.go`.
+- Prior shipped plan: `docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md`.
+- Companion plan (dependency for Unit 3): `docs/plans/2026-04-19-003-feat-unified-auth-manager-plan.md`.
+- Repo conventions: `AGENTS.md` (machine-vs-printed rule, glossary, commit style).
+- Product philosophy: `README.md` "Absorb and Transcend", "Dual interface from one spec".
diff --git a/docs/plans/2026-04-19-003-feat-unified-auth-manager-plan.md b/docs/plans/2026-04-19-003-feat-unified-auth-manager-plan.md
new file mode 100644
index 00000000..c52adce4
--- /dev/null
+++ b/docs/plans/2026-04-19-003-feat-unified-auth-manager-plan.md
@@ -0,0 +1,330 @@
+---
+title: "feat(cli): unified auth manager for api-key credentials"
+type: feat
+status: active
+date: 2026-04-19
+---
+
+# feat(cli): unified auth manager for api-key credentials
+
+## Overview
+
+Add a `printing-press auth` subcommand group that manages api-key credentials for every printed CLI in one place. Today users juggle 15+ env vars (`ESPN_KEY`, `HUBSPOT_ACCESS_TOKEN`, `DUB_TOKEN`, `LINEAR_API_KEY`, `CAL_COM_TOKEN`, `KALSHI_API_KEY`, etc.) by hand with no visibility, no doctor, and no shared store. This plan ships the minimum viable unified auth: a file at `~/.pp/credentials.json`, a tty-only capture flow, a doctor that detects env-vs-store divergence, and a generator-reserved `authload.Get` helper that new printed CLIs call from their HTTP client.
+
+OAuth is explicitly out of scope for this plan. No current library CLI uses OAuth. When one does, a separate plan will design the loopback flow against a real provider, not three hypothetical ones.
+
+## Problem Frame
+
+Every printed CLI in the library reads its own env var: `ESPN_KEY` for ESPN, `HUBSPOT_ACCESS_TOKEN` for HubSpot, `DUB_TOKEN` for Dub, `LINEAR_API_KEY` for Linear, `CAL_COM_TOKEN` for Cal.com, `KALSHI_API_KEY` for Kalshi, `FLIGHTGOAT_API_KEY_AUTH` for FlightGoat, and so on. The onboarding path is: get a token from the vendor's dashboard, paste an `export` line into `.zshrc` or a project `.env`, repeat for each CLI. There is no visibility into what is linked, no expiry awareness, no `doctor` command, and no way to know a token has gone stale except by seeing 401s.
+
+`/ppl` already handles discovery, install, and routing. The remaining gap is credentials. That is what this plan closes.
+
+## Requirements Trace
+
+- R1. One command flow (`printing-press auth link <api>`) that captures an api key via a tty-only prompt and stores it.
+- R2. Centralized store at `~/.pp/credentials.json` with 0600 perms, 0700 parent dir, atomic writes.
+- R3. Printed CLIs keep their existing env-var-first behaviour. The store is additive and never silently overrides a set env var.
+- R4. `auth doctor` surfaces env-vs-store divergence, permission drift, and missing credentials so stale tokens are visible.
+- R5. An `authload.Get` helper that returns the token in-process (no `os.Setenv`). Generator emits one call in new printed CLIs' HTTP client construction.
+- R6. `auth list`, `auth remove`, `auth fix-perms` round out the surface.
+
+## Scope Boundaries
+
+In scope:
+- `printing-press auth link`, `auth list`, `auth remove`, `auth doctor`, `auth fix-perms`.
+- File-backed credential store at `~/.pp/credentials.json`.
+- `cliutil/authload` package with `Get(slug)`.
+- Generator template change so new printed CLIs call `authload.Get` in HTTP client construction.
+
+### Deferred to Separate Tasks
+
+- OAuth loopback flow, PKCE, state, provider registry. No current library CLI uses OAuth; when one does, that is its own plan.
+- OS keychain backends (macOS Keychain, Linux Secret Service, Windows Credential Manager). File store covers P1; keychain is a follow-up plan.
+- Subprocess env-scrub helper (`SafeExec`). No printed CLI shells out to secret-carrying subprocesses today. If one appears, handle it then.
+- HMAC per-entry integrity. Same-UID read access to the store is the threat model; HMAC with a same-directory key does not raise the bar.
+- Single-writer lock file. Last-write-wins is acceptable for a file a single user rarely touches from two places simultaneously. Revisit if contention shows up.
+- Scorer dimension enforcing `authload.Get` usage. Template emits it; enforcement machinery is premature.
+- Shell-file origin scan for divergence attribution. Brittle; `auth doctor` just says "env var is set in current shell" and moves on.
+
+## Context & Research
+
+### Relevant code and patterns
+
+- `internal/megamcp/auth.go` - existing env-var-to-header expansion via `ApplyAuthFormat`, `BuildAuthHeader`, `hasAuthConfigured`. Read path stays compatible; the store supplies values when env vars are unset.
+- `internal/megamcp/manifest.go` - `ToolsManifest.Auth` declares type, env vars, format string. The store indexes by API slug; printed CLIs map their slug to the primary env var they already know.
+- `internal/cliutil/` - generator-reserved package shipped into every printed CLI. `authload` lands here next to `FanoutRun` and `CleanText`.
+- `internal/pipeline/toolsmanifest.go` - generator side that knows each API's primary env var name. Template emits one `authload.Get` call in HTTP client construction for new printed CLIs.
+- `printing-press-library/registry.json` - declares `auth_type` and `env_vars` per CLI. `auth doctor` and `auth list` join against it to know which APIs need credentials.
+- `internal/cli/` - existing Cobra wiring for other subcommand groups.
+
+### Institutional learnings
+
+- AGENTS.md machine-vs-printed rule: the generator template change here affects new printed CLIs going forward. Already-printed CLIs continue to work unchanged because env-var-first is preserved.
+- PP exit-code contract (0/2/3/4/5/7) applies to every auth subcommand. 4 = auth error, 5 = API/filesystem error, 2 = usage.
+- README "Dual interface from one spec": the store can be read by megamcp and by future surfaces (trigger daemon if it ever lands) without duplicating logic.
+
+### External references
+
+- None needed. This is a straightforward file-backed credential store with tty capture.
+
+## Key Technical Decisions
+
+KTD-1. Landing repo is `cli-printing-press`. Auth is a machine capability; generator templates need the helper available in every new printed CLI.
+
+KTD-2. Credential resolution order (read path), explicit and stable:
+```
+hasCred(api) =
+  os.Getenv(primaryEnvVar)      # existing behaviour, unchanged
+    || authstore.Read(api)       # shared file at ~/.pp/credentials.json
+    || nil
+```
+Env-var-wins preserves every already-printed CLI's behaviour without template changes. The store is additive. The silent-stale-env-var class of bugs is addressed by KTD-4.
+
+KTD-3. `authload.Get(slug) -> (string, error)` returns the token for in-process use directly in an HTTP header. No `os.Setenv`. Rationale: `os.Setenv` inherits into subprocesses, shows up in `/proc/<pid>/environ`, and survives into core dumps. An in-process return value closes those paths for new printed CLIs at zero cost. Already-printed CLIs keep their existing env-var reads and are unaffected.
+
+KTD-4. Env-var-wins is preserved (KTD-2), AND divergence is first-class in the tooling:
+- `auth doctor` detects "env var and store both present, different values" as a yellow finding.
+- `auth link` post-success prints a warning if the corresponding env var is set in the current shell.
+- `auth list` adds a `shadowed_by_env` column.
+
+Rationale: silent stale env vars are the most common failure mode after a fresh `auth link`. Without surfacing divergence, users see "linked" but get 401s and attribute them to the API.
+
+KTD-5. P1 ships api-key capture only. Tty-only prompt, echo disabled, argv-supplied tokens refused. OAuth is a separate plan triggered when a real library CLI needs it.
+
+KTD-6. File store with 0600 perms and atomic `O_EXCL` tempfile + rename. No HMAC, no lock file. Threat model: same-UID read-access defeats any on-disk mitigation short of a real keychain, and a keychain backend is its own plan. Atomic writes prevent torn files. Last-write-wins is acceptable for a single-user store.
+
+## Open Questions
+
+### Resolved During Planning
+
+- Q: Ship OAuth in this plan? A: No. KTD-5. Library is api-key. OAuth gets its own plan when a library CLI needs it.
+- Q: HMAC integrity on entries? A: No. KTD-6. Integrity theater given the threat model.
+- Q: Keychain backend in P1? A: No. Separate plan.
+- Q: Preserve env-var-wins or flip to store-wins? A: Preserve. KTD-2 + KTD-4.
+- Q: Ship `SafeExec` subprocess guardrail? A: No. No CLI shells out to secret-carrying subprocesses today.
+
+### Deferred to Implementation
+
+- Exact column widths for `auth list` TTY output. Decide at first render against a real library.
+- Whether `auth doctor` probes live API health. Default offline-only; a `--network` flag can add online checks later if wanted.
+
+## 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.
+
+### Command surface
+
+```
+printing-press auth
+  link <api>          # tty-only api-key prompt, writes to store
+  list [--api X]      # linked creds, shadowed_by_env column
+  remove <api>        # delete the entry
+  doctor              # env divergence, perms, missing credentials
+  fix-perms           # restore 0600 / 0700 across ~/.pp
+```
+
+### Store layout
+
+```
+~/.pp/
+  credentials.json    # 0600; JSON
+```
+
+Credential entry shape (directional, not implementation):
+
+```
+{
+  "schema_version": 1,
+  "credentials": {
+    "hubspot": {
+      "type": "api_key",
+      "token": "...",
+      "linked_at": "2026-04-19T12:05:00Z"
+    },
+    "linear": {
+      "type": "api_key",
+      "token": "...",
+      "linked_at": "2026-04-19T12:10:00Z"
+    }
+  }
+}
+```
+
+`schema_version` is present from day one so future additive fields (scopes, expires_at when OAuth lands) can ship without a migration.
+
+## Implementation Units
+
+- [ ] Unit 1: authstore file backend + authload.Get
+
+  Goal: Ship the on-disk store and the generator-reserved helper that printed CLIs consume.
+
+  Requirements: R2, R3, R5
+
+  Dependencies: None.
+
+  Files:
+  - Create: `internal/authstore/store.go` (Read, Write, Delete, List).
+  - Create: `internal/authstore/file.go` (file IO with O_EXCL + rename, perm enforcement).
+  - Create: `internal/cliutil/authload/authload.go` (`Get(slug)` resolving env-var then store per KTD-2).
+  - Test: `internal/authstore/store_test.go`, `file_test.go`, `internal/cliutil/authload/authload_test.go`.
+
+  Approach:
+  - Parent dir `~/.pp/` created with 0700 on first write. `credentials.json` written 0600 via `O_EXCL` tempfile + rename.
+  - Store JSON is versioned with `schema_version: 1` so additive fields land cleanly later.
+  - Missing store file is not an error for read callers; `Get` returns "not found" cleanly.
+  - `Get(slug)`: read `os.Getenv(primaryEnvVar)` first, fall back to store, return `(token, nil)` or `("", ErrNotFound)`.
+
+  Execution note: test-first. Permission bits and atomic-write discipline are easier to verify upfront than retrofit.
+
+  Patterns to follow: `internal/cliutil/` package conventions for generator-reserved helpers.
+
+  Test scenarios:
+  - Happy path: Write then Read round-trips a single slug.
+  - Happy path: fresh install creates `~/.pp/` with 0700 and `credentials.json` with 0600.
+  - Happy path: `Get` with env var set returns the env value without touching the store file.
+  - Happy path: `Get` with env var unset and store entry present returns the stored token.
+  - Edge case: missing parent dir is created atomically with 0700.
+  - Edge case: missing store file causes `Get` to return `ErrNotFound`, not an error.
+  - Edge case: corrupted JSON returns a typed error; file is not overwritten.
+  - Error path: write fails mid-rename; original file is intact; tempfile is cleaned up.
+  - Error path: `credentials.json` exists with 0644 perms; Write refuses with a typed "unsafe perms" error suggesting `auth fix-perms`.
+
+  Verification: `authload.Get("hubspot")` reads a token from `~/.pp/credentials.json` on a fresh machine and returns it without populating `os.Environ()`.
+
+- [ ] Unit 2: auth link, auth list, auth remove
+
+  Goal: Ship the capture flow and read-only inspection commands.
+
+  Requirements: R1, R6
+
+  Dependencies: Unit 1.
+
+  Files:
+  - Create: `internal/cli/auth_cmd.go`.
+  - Create: `internal/authstore/prompt.go` (tty-only api-key prompt with echo disabled).
+  - Modify: `cmd/printing-press/main.go` to register the `auth` subcommand group.
+  - Test: `internal/cli/auth_cmd_test.go`, `internal/authstore/prompt_test.go`.
+
+  Approach:
+  - `auth link <api>`: resolve the API slug against the library registry (`printing-press-library/registry.json`) to confirm it is a known CLI. Reject unknown slugs with exit 3 and a "did you mean" list. Prompt for the api key via tty with echo disabled. Write to the store.
+  - `auth link --from-stdin <api>`: accept the token on stdin for CI use. Fail if stdin is a tty.
+  - Reject `--token <value>` argv flag with a named error to prevent secrets in shell history.
+  - `auth list`: read the store, join against the registry, print table in TTY and JSON when piped. Columns: slug, type, linked_at, shadowed_by_env.
+  - `auth remove <api>`: delete the entry. Exit 0 even if the entry did not exist (idempotent).
+  - Post-success on `auth link`: if the corresponding env var is set in the current shell, print a warning with `unset <VAR>` guidance (KTD-4).
+
+  Patterns to follow: existing Cobra subcommand style under `internal/cli/`.
+
+  Test scenarios:
+  - Happy path: `auth link hubspot` with a simulated tty captures the token and writes it to the store.
+  - Happy path: `auth link --from-stdin hubspot < token.txt` works non-interactively.
+  - Happy path: `auth list --json` auto-triggers when stdout is piped.
+  - Happy path: `auth remove hubspot` removes the entry and reports success.
+  - Edge case: `auth link hubspot` with non-tty stdin and no `--from-stdin` refuses with exit 2.
+  - Edge case: `auth link hubspot --token abc` is refused with a named error.
+  - Edge case: `auth link unknown-api` returns exit 3 with suggestions from the registry.
+  - Edge case: `auth link hubspot` succeeds while `HUBSPOT_ACCESS_TOKEN` is set in the env: entry is stored AND a warning names the env var.
+  - Edge case: `auth remove hubspot` on a missing entry returns exit 0.
+  - Error path: `auth list` with a missing store file returns exit 0 and empty output.
+
+  Verification: on a fresh machine, `auth link hubspot && hubspot-pp-cli contacts list` succeeds without the user setting `HUBSPOT_ACCESS_TOKEN` (once the generator template change in Unit 4 has been applied to the HubSpot printed CLI).
+
+- [ ] Unit 3: auth doctor + auth fix-perms
+
+  Goal: Ship the diagnostic surface.
+
+  Requirements: R4, R6
+
+  Dependencies: Unit 1, Unit 2.
+
+  Files:
+  - Create: `internal/authstore/doctor.go`.
+  - Create: `internal/cli/auth_doctor_cmd.go` (or extend `auth_cmd.go`).
+  - Test: `internal/authstore/doctor_test.go`.
+
+  Approach:
+  - For each slug in `printing-press-library/registry.json`, doctor checks:
+    - store entry present?
+    - primary env var set?
+    - if both, do values match (compare by SHA-256 to avoid logging secrets)?
+    - `credentials.json` perms are 0600?
+    - `~/.pp/` perms are 0700?
+  - Output: traffic-light table (green / yellow / red). Yellow findings are informational; red findings highlight actionable fixes.
+  - `auth fix-perms`: chmod `~/.pp/` to 0700 and `credentials.json` to 0600. Idempotent. Reports what changed.
+  - `auth doctor --json` produces machine-readable output for agent consumption.
+
+  Patterns to follow: `doctor` subcommands inside printed CLIs.
+
+  Test scenarios:
+  - Happy path: green for a slug with store entry, env unset.
+  - Happy path: green for a slug with env set, store unset (current behaviour, still works).
+  - Yellow finding: env and store both present with identical values (info: "store unused while env is set").
+  - Yellow finding: env and store both present with DIFFERENT values (flag as divergence).
+  - Red finding: `credentials.json` is 0644; doctor suggests `auth fix-perms`.
+  - Red finding: `~/.pp/` is 0755; doctor suggests `auth fix-perms`.
+  - Red finding: slug in registry has neither env nor store entry (not linked).
+  - Happy path: `auth fix-perms` on a seeded wrong-perm setup restores 0700/0600 and reports both changes.
+  - Integration: `auth doctor --json` returns a schema suitable for agent parsing.
+
+  Verification: doctor classifies a seeded set of 5 slugs covering all finding categories in one pass.
+
+- [ ] Unit 4: Generator template change + docs
+
+  Goal: New printed CLIs call `authload.Get` in HTTP client construction. Documentation updated.
+
+  Requirements: R3, R5
+
+  Dependencies: Unit 1.
+
+  Files:
+  - Modify: generator HTTP client template under `internal/generator/` (or wherever the HTTP client template lives).
+  - Modify: `README.md` to add a short "Auth" section.
+  - Modify: `AGENTS.md` glossary to add `authstore`, `authload`, and `~/.pp/` layout.
+  - Modify: `ONBOARDING.md` to reference `printing-press auth link` as the setup path.
+  - Test: generator template snapshot test updated.
+
+  Approach:
+  - Template emits one call site per printed CLI: HTTP client reads its token via `authload.Get("<slug>")` and places it in the outbound header per the manifest's auth format. Env var fallback is inside `Get` (KTD-2), so the template has no conditional.
+  - Already-printed CLIs are untouched. Regenerating a CLI picks up the new pattern.
+  - Documentation states the env-var-wins precedence clearly.
+
+  Patterns to follow: existing HTTP client template.
+
+  Test scenarios:
+  - Happy path: a freshly generated CLI contains the `authload.Get` import and call in the expected file.
+  - Integration: regenerating one catalog API produces a CLI that imports `authload`, calls `Get`, and successfully reads a token from the store at runtime.
+
+  Verification: a regenerated library CLI makes an authenticated API call reading from `~/.pp/credentials.json` without the user exporting the env var.
+
+## System-Wide Impact
+
+- Interaction graph: `internal/authstore` is a new leaf package. `cliutil/authload` sits between printed CLIs and the store. Megamcp's existing auth path is unchanged and continues to use env vars plus manifest format strings; if megamcp later wants store fallback, it can import `authload.Get` with no other changes.
+- Error propagation: all auth errors map to exit 4. Store-integrity and filesystem errors map to exit 5. Usage errors (argv-supplied token, non-tty without `--from-stdin`, unknown slug) map to exit 2 or 3.
+- State lifecycle risks: one new file at `~/.pp/credentials.json`. Atomic writes prevent torn files. Perm enforcement on write catches drift early.
+- API surface parity: no MCP-side equivalents in this plan. If megamcp-side `auth_status` / `auth_list` meta-tools are wanted, they are a trivial follow-up since the store read path is already there.
+- Integration coverage: one cross-process integration test is required: super-CLI... no, there is no super-CLI. Just: `auth link` writes, `hubspot-pp-cli contacts list` reads via `authload.Get` and succeeds.
+- Unchanged invariants: every already-printed CLI keeps reading env vars as its primary credential source. Env-var-wins precedence is preserved. The library registry stays authoritative for which slugs exist.
+
+## Risks & Dependencies
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| Stale env var silently shadows fresh `auth link` token | High | Med | KTD-4: `auth link` post-success warning when env var is set; `auth list` shadow column; `auth doctor` flags divergence |
+| Store file is exfiltrated via same-UID process (editor extension, postinstall script) | Med | High | Documented scope: same-UID read is the threat model a file store does not defend against; keychain backend is a follow-up plan |
+| Store file is backed up via Time Machine / iCloud / Dropbox | Med | Med | Document the exclusion procedure in README; no `.nosync` marker, which is folklore |
+| Generator template change lands without regenerating existing library CLIs | Low | Low | Already-printed CLIs keep working via env vars; regeneration is opportunistic, not forced |
+| Users expect OAuth and file issues | Med | Low | README states clearly that OAuth is a follow-up plan; scope boundary explicit |
+| Two `auth link` invocations race and corrupt the store | Low | Low | Atomic rename prevents torn files; last-write-wins is acceptable for a single-user store with low contention; add a lock later if real-world contention appears |
+
+## Documentation / Operational Notes
+
+- Changelog scope `cli` for every unit in this plan.
+- No runtime migration. Store is created lazily on first `auth link`. Users who keep using env vars see zero change.
+- No feature flag. Single-binary release.
+- README gets a short "Auth" section near setup. ONBOARDING.md points new users at `auth link` as the preferred path.
+
+## Sources & References
+
+- Related code: `internal/megamcp/auth.go`, `internal/megamcp/manifest.go`, `internal/cliutil/`, `internal/pipeline/toolsmanifest.go`, `internal/generator/`, `internal/cli/`.
+- Repo conventions: `AGENTS.md` (machine-vs-printed rule, glossary, commit style), `README.md` ("Absorb and Transcend" philosophy).
+- Killed-sibling plan: the super-CLI plan (`docs/plans/2026-04-19-002-feat-super-cli-run-namespace-plan.md`) was written alongside this one but retired because `/ppl` already provides discovery, install, and routing. This plan stands alone.
diff --git a/docs/plans/2026-04-19-004-feat-auth-doctor-plan.md b/docs/plans/2026-04-19-004-feat-auth-doctor-plan.md
new file mode 100644
index 00000000..f5544532
--- /dev/null
+++ b/docs/plans/2026-04-19-004-feat-auth-doctor-plan.md
@@ -0,0 +1,230 @@
+---
+title: "feat(cli): auth doctor - env-var visibility across installed printed CLIs"
+type: feat
+status: active
+date: 2026-04-19
+---
+
+# feat(cli): auth doctor - env-var visibility across installed printed CLIs
+
+## Overview
+
+Add `printing-press auth doctor`. One command that scans every installed printed CLI's `tools-manifest.json`, checks whether the declared env vars are set, prints a traffic-light table with fingerprints, and calls out obvious problems (truncated values, missing required tokens). Zero new state on disk, zero regeneration required. Piggybacks on the manifests megamcp already reads.
+
+This supersedes the more ambitious unified auth plan (`docs/plans/2026-04-19-003-feat-unified-auth-manager-plan.md`) which added a credential store, OAuth flow, and template changes. The store was marginal value for most users. The diagnostic is the piece that clearly beats the status quo today.
+
+## Problem Frame
+
+A typical failure today: user runs `hubspot-pp-cli contacts list`, gets a 401, does not know whether `HUBSPOT_ACCESS_TOKEN` is unset, stale, truncated, or the token is simply invalid at the vendor. They have to inspect their shell, test against the vendor, and guess. An agent hits the same wall and cannot self-correct.
+
+A unified diagnostic changes that: one command answers "what is the auth state across every PP CLI I have installed?" The answer is offline, fast, and pipes cleanly to an agent.
+
+No store is needed. No CLI regeneration is needed. Every printed CLI already ships a `tools-manifest.json` that declares its auth type and env var names, and megamcp already knows how to read those manifests.
+
+## Requirements Trace
+
+- R1. `printing-press auth doctor` scans installed printed CLIs and prints per-API auth status.
+- R2. Status must distinguish: env var set and well-formed, env var set but suspicious (empty, too short for the declared type), env var unset, API has no auth requirement.
+- R3. Fingerprints show the first 4 characters of each set value so users can tell "this is my new token" vs "this is my old token" without the full secret leaking.
+- R4. `--json` output for agents (auto-triggered when stdout is piped).
+- R5. Works against whatever printed CLIs the user has installed under `~/printing-press/library/<api>/`. Does not require a separate registry or store.
+
+## Scope Boundaries
+
+In scope:
+- `printing-press auth doctor` subcommand.
+- Scan of `~/printing-press/library/<api>/tools-manifest.json` files.
+- Optional `--catalog` flag to additionally include APIs from the embedded `catalog.FS` that are not yet installed, so users see "you could install X but have not."
+- Human-friendly table in TTY, auto-JSON when piped.
+
+### Deferred to Separate Tasks
+
+- Credential store (`~/.pp/credentials.json`). See the superseded plan (`docs/plans/2026-04-19-003`). Revisit only if users actually report pain managing many tokens.
+- `auth link`, `auth list`, `auth remove`, `auth fix-perms`. Deferred with the store.
+- OAuth loopback flows. Separate plan when a library CLI needs OAuth.
+- Live API health probes (actually calling the vendor with the token). Could be a `--network` flag later.
+- Shell-file origin attribution (parsing `.zshrc` to find where a var was set). Brittle, skip.
+
+## Context & Research
+
+### Relevant code and patterns
+
+- `internal/megamcp/manifest.go` - `ToolsManifest` struct with `Auth.Type`, `Auth.EnvVars`, `Auth.Format`. Doctor reads the same shape.
+- `internal/megamcp/auth.go` - `hasAuthConfigured` and `ApplyAuthFormat` show how megamcp already reasons about env-var presence. Reuse the same signals.
+- `internal/megamcp/metatools.go` - `library_info` handler already enumerates installed manifests. Doctor is the CLI-side analogue with environment status layered on.
+- `internal/catalog/` + `catalog.FS` - embedded catalog of 18 baseline APIs. Source for the `--catalog` mode showing "not installed" entries.
+- `internal/cli/` - existing Cobra subcommand wiring. Auth group lives beside `generate`, `verify`, `scorecard`.
+- Local library layout: `~/printing-press/library/<api>/tools-manifest.json` per `AGENTS.md` glossary.
+
+### Institutional learnings
+
+- AGENTS.md machine-vs-printed rule: this is a machine change (new subcommand on the `printing-press` binary), no printed CLI changes.
+- PP exit-code contract: doctor uses 0 for "ran successfully, findings inside"; 5 only if the scan itself fails (e.g., library dir unreadable).
+
+### External references
+
+- None needed. Pure local scan.
+
+## Key Technical Decisions
+
+KTD-1. Doctor reads `~/printing-press/library/<api>/tools-manifest.json` as the source of truth for which APIs to check and what env vars each one declares. Rationale: same file megamcp reads. Staying on one manifest keeps both surfaces consistent as the library evolves.
+
+KTD-2. Default mode shows installed CLIs only. `--catalog` adds the embedded baseline catalog (18 APIs in `catalog.FS`) so the user sees install suggestions alongside runtime status. Rationale: most users want "what is wrong with what I have"; the discovery use case is secondary and flag-gated.
+
+KTD-3. Fingerprints show the first 4 characters of each set value. Never show the full value, never log it. Rationale: enough signal to distinguish tokens ("pat-abc..." vs "xoxb-..."), no leakage risk.
+
+KTD-4. Suspicious-value detection is heuristic, not schema-validated. Minimum lengths per auth type (api_key >= 8, bearer_token >= 20) flag obviously truncated values. Rationale: false positives are harmless (user confirms the value is correct); false negatives are acceptable because this is a nudge, not a gate.
+
+KTD-5. Output is a table in TTY and auto-JSON when stdout is piped. Rationale: matches PP's existing agent-native behaviour across every other subcommand.
+
+## Open Questions
+
+### Resolved During Planning
+
+- Q: Where does doctor find the list of APIs to check? A: Installed manifests at `~/printing-press/library/<api>/tools-manifest.json`. KTD-1.
+- Q: Include not-yet-installed catalog APIs? A: Only under `--catalog`. KTD-2.
+- Q: Show full token values? A: No, first 4 chars only. KTD-3.
+- Q: Probe the live API? A: No. Offline-only v1. A `--network` flag is future work.
+
+### Deferred to Implementation
+
+- Exact column widths and header style for the TTY table. Decide at first render against a real library.
+- Whether to include `auth_type: composed` (cookie-based) APIs in the default scan. Probably yes, but the field to check differs from `env_vars`. Confirm when the implementer sees the actual manifest shape.
+
+## 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.
+
+### Output shape (directional)
+
+```
+$ printing-press auth doctor
+
+API          Type      Env Var                    Status      Value
+-----------  --------  -------------------------  ----------  -----------
+dub          api_key   DUB_TOKEN                  ok          dub_...
+espn         api_key   ESPN_KEY                   not set     -
+hubspot      api_key   HUBSPOT_ACCESS_TOKEN       ok          pat-...
+kalshi       api_key   KALSHI_API_KEY             suspicious  abc (too short)
+linear       api_key   LINEAR_API_KEY             ok          lin_...
+cal-com      api_key   CAL_COM_TOKEN              not set     -
+hackernews   none      -                          n/a         -
+
+Summary: 3 ok, 1 suspicious, 2 not set, 1 no auth required
+```
+
+`--json` shape (directional):
+
+```json
+{
+  "summary": {"ok": 3, "suspicious": 1, "not_set": 2, "no_auth": 1},
+  "apis": [
+    {"api": "dub", "type": "api_key", "env_var": "DUB_TOKEN", "status": "ok", "fingerprint": "dub_"},
+    {"api": "kalshi", "type": "api_key", "env_var": "KALSHI_API_KEY", "status": "suspicious", "fingerprint": "abc", "reason": "value shorter than 8 chars"}
+  ]
+}
+```
+
+## Implementation Units
+
+- [ ] Unit 1: Library scan + status classifier
+
+  Goal: Ship the core scan function that reads installed manifests and classifies each API's env-var status.
+
+  Requirements: R1, R2, R3, R5
+
+  Dependencies: None.
+
+  Files:
+  - Create: `internal/authdoctor/scan.go` (scan `~/printing-press/library/<api>/tools-manifest.json`, load into the existing `megamcp.ToolsManifest` shape).
+  - Create: `internal/authdoctor/classify.go` (given a manifest + current env, return a typed `Finding`).
+  - Create: `internal/authdoctor/fingerprint.go` (first-4-chars rendering with non-printable guard).
+  - Test: `internal/authdoctor/scan_test.go`, `classify_test.go`, `fingerprint_test.go`.
+
+  Approach:
+  - Reuse `internal/megamcp/manifest.go` types for manifest parsing. No duplicate parser.
+  - Classifier states: `ok`, `suspicious`, `not_set`, `no_auth`, `unknown` (manifest declares an auth type the classifier does not know).
+  - Suspicious thresholds: api_key minimum 8 chars, bearer_token minimum 20 chars. These live in a small table inside `classify.go` and are easy to tune.
+  - `composed` auth type is treated as `ok` if any of its declared env vars is set and `not_set` if none are set. Do not over-engineer composed-cookie inspection in v1.
+  - Scan gracefully handles a missing `~/printing-press/library/` directory by returning an empty result with no error. That is the "fresh install" case.
+
+  Patterns to follow: `internal/megamcp/metatools.go` for manifest iteration. Keep the scan read-only and idempotent.
+
+  Test scenarios:
+  - Happy path: seeded library with three manifests (api_key, bearer_token, no auth) classifies correctly when env is set appropriately.
+  - Happy path: env var set to a long well-formed value produces `ok` with the first-4 fingerprint.
+  - Edge case: env var set to a short value produces `suspicious` with a reason string.
+  - Edge case: env var set but empty string produces `suspicious` with "empty value".
+  - Edge case: manifest declares `auth.type: none` produces `no_auth`.
+  - Edge case: manifest declares a type the classifier does not know produces `unknown` without failing the whole scan.
+  - Edge case: missing `~/printing-press/library/` returns an empty result and no error.
+  - Edge case: manifest file with invalid JSON is reported as one `Finding` with status `unknown` and a "manifest parse error" reason; scan continues for other APIs.
+  - Edge case: `composed` auth with two env vars - one set, one unset - classifies as `ok` (at least one present).
+  - Edge case: fingerprint of a value containing control characters or non-printables renders as `?` for those positions rather than leaking raw bytes.
+
+  Verification: scanning a seeded fixture library covering all classifier states produces the expected set of `Finding` objects.
+
+- [ ] Unit 2: `auth doctor` command + rendering + docs
+
+  Goal: Ship the user-facing Cobra command, table and JSON renderers, and update README.
+
+  Requirements: R1, R4
+
+  Dependencies: Unit 1.
+
+  Files:
+  - Create: `internal/cli/auth_doctor_cmd.go` (Cobra wiring for `printing-press auth doctor`).
+  - Create: `internal/authdoctor/render.go` (TTY table + JSON renderers).
+  - Modify: `cmd/printing-press/main.go` to register the `auth` subcommand group with `doctor` under it.
+  - Modify: `README.md` to add a short "Diagnosing auth problems" note linking to `auth doctor`.
+  - Modify: `AGENTS.md` glossary to add `internal/authdoctor/`.
+  - Test: `internal/cli/auth_doctor_cmd_test.go`, `internal/authdoctor/render_test.go`.
+
+  Approach:
+  - Register an `auth` parent command with `doctor` as the only current child. Future auth subcommands (if they ever land) attach to the same parent.
+  - Renderer chooses table vs JSON based on `isatty(stdout)` with `--json` as an explicit override.
+  - `--catalog` flag adds entries from `catalog.FS` whose slugs are not already in the installed manifest set. Each catalog-only entry is rendered with status `not installed`.
+  - Non-zero exit only when the scan itself fails (e.g., library dir exists but is unreadable). Findings with `not_set` or `suspicious` do not change the exit code; the command is diagnostic, not gating.
+  - README section is ~10 lines. ONBOARDING.md does not need changes for this.
+
+  Patterns to follow: existing Cobra command style in `internal/cli/`. Auto-JSON-when-piped pattern from printed CLIs.
+
+  Test scenarios:
+  - Happy path: TTY output for the seeded fixture library renders a stable column layout.
+  - Happy path: piped stdout auto-switches to JSON without a flag.
+  - Happy path: `--json` forces JSON even in TTY.
+  - Happy path: `--catalog` merges installed + catalog entries; catalog-only entries show `not installed`.
+  - Edge case: empty library renders "No printed CLIs installed." summary and exits 0.
+  - Edge case: output rendering of a value containing a terminal-escape sequence strips or escapes the sequence (defensive; aligns with KTD-3 safety framing).
+  - Error path: `~/printing-press/library/` exists but is unreadable (perm denied) returns exit 5 with an actionable message; scan does not partially render.
+
+  Verification: on a real machine with 2+ installed printed CLIs, `printing-press auth doctor` prints a correct table. `printing-press auth doctor --json | jq` round-trips cleanly.
+
+## System-Wide Impact
+
+- Interaction graph: `internal/authdoctor` is a new leaf package. It reads the manifest type from `internal/megamcp/manifest.go`. Megamcp is not modified.
+- Error propagation: exit 0 for "scan ran, here are findings". Exit 5 only for scan failures. Exit 2 for usage errors (unknown flag).
+- State lifecycle risks: none. Doctor is read-only.
+- API surface parity: no MCP-side meta-tool in this plan. If someone wants `auth_doctor` exposed via megamcp later, it is a trivial wrapper.
+- Unchanged invariants: no printed CLI changes. No new files on disk. No changes to existing env-var behaviour. Users who never run `auth doctor` see zero difference.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Fingerprint leaks enough characters to identify a token via side-channel | First 4 chars only; value-type prefixes (`pat-`, `xoxb-`, `Bearer `) are the same across all tokens of that type, so 4 chars do not narrow to a user-specific secret |
+| Doctor produces false-positive "suspicious" findings for intentionally short tokens | Thresholds are heuristic and tunable in one file; false positives are nudge-level, not blocking |
+| Library layout changes (`~/printing-press/library/<api>/`) in a future refactor | Scan goes through a small helper that resolves the library root; one function to update |
+| Manifest schema drift adds fields doctor does not understand | Doctor only reads `Auth.Type` and `Auth.EnvVars`; extra fields are ignored |
+
+## Documentation / Operational Notes
+
+- Changelog scope `cli`.
+- No migration. No feature flag. Single binary release.
+- README gets a short "Diagnosing auth" section. AGENTS.md glossary adds the new package.
+
+## Sources & References
+
+- Related code: `internal/megamcp/manifest.go`, `internal/megamcp/auth.go`, `internal/megamcp/metatools.go`, `internal/catalog/`, `internal/cli/`.
+- Superseded plan: `docs/plans/2026-04-19-003-feat-unified-auth-manager-plan.md` (credential store + OAuth; deferred).
+- Repo conventions: `AGENTS.md` (machine-vs-printed rule, glossary, commit style).
diff --git a/internal/authdoctor/classify.go b/internal/authdoctor/classify.go
new file mode 100644
index 00000000..491addc9
--- /dev/null
+++ b/internal/authdoctor/classify.go
@@ -0,0 +1,138 @@
+package authdoctor
+
+import (
+	"fmt"
+
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
+)
+
+// minLengthByType gives the minimum expected length of a well-formed
+// credential for common auth types. Values shorter than the threshold
+// produce StatusSuspicious with a "too short" reason. These are
+// heuristic nudges, not validation; false positives are acceptable.
+var minLengthByType = map[string]int{
+	"api_key":      8,
+	"bearer_token": 20,
+}
+
+// getEnv looks up an env var. It is parameterised on the classifier so
+// tests can inject a synthetic environment without touching os.Setenv.
+type getEnv func(string) string
+
+// Classify inspects one API manifest against the provided environment
+// and returns the Findings it produces. An API may yield:
+//
+//   - a single Finding with StatusNoAuth when the manifest declares
+//     auth type "none" or has no env vars
+//   - one Finding per declared env var otherwise
+//   - a single Finding with StatusUnknown when the manifest is nil
+//     or malformed
+//
+// slug is the API identifier used in output. The manifest's own
+// Auth.Type is reported verbatim.
+func Classify(slug string, manifest *pipeline.ToolsManifest, env getEnv) []Finding {
+	if manifest == nil {
+		return []Finding{{
+			API:    slug,
+			Status: StatusUnknown,
+			Reason: "manifest missing or unreadable",
+		}}
+	}
+
+	authType := manifest.Auth.Type
+	if authType == "" || authType == "none" {
+		return []Finding{{
+			API:    slug,
+			Type:   displayType(authType),
+			Status: StatusNoAuth,
+		}}
+	}
+
+	if len(manifest.Auth.EnvVars) == 0 {
+		return []Finding{{
+			API:    slug,
+			Type:   authType,
+			Status: StatusUnknown,
+			Reason: "auth type declared but no env_vars listed in manifest",
+		}}
+	}
+
+	findings := make([]Finding, 0, len(manifest.Auth.EnvVars))
+	for _, envVar := range manifest.Auth.EnvVars {
+		findings = append(findings, classifyEnv(slug, authType, envVar, env))
+	}
+	return findings
+}
+
+// classifyEnv builds one Finding for a single (api, auth-type, env-var) triple.
+func classifyEnv(slug, authType, envVar string, env getEnv) Finding {
+	value := env(envVar)
+	base := Finding{
+		API:    slug,
+		Type:   authType,
+		EnvVar: envVar,
+	}
+
+	if value == "" {
+		base.Status = StatusNotSet
+		return base
+	}
+
+	// Suspicious-value heuristics.
+	if reason := suspiciousReason(authType, value); reason != "" {
+		base.Status = StatusSuspicious
+		base.Reason = reason
+		base.Fingerprint = Fingerprint(value)
+		return base
+	}
+
+	base.Status = StatusOK
+	base.Fingerprint = Fingerprint(value)
+	return base
+}
+
+// suspiciousReason returns a non-empty reason when a set value looks
+// obviously malformed. Returns empty when the value looks acceptable.
+func suspiciousReason(authType, value string) string {
+	// Leading or trailing whitespace is almost always a paste error.
+	if trimmed := trimmedLen(value); trimmed != len(value) {
+		return "value has surrounding whitespace"
+	}
+
+	minLen, ok := minLengthByType[authType]
+	if !ok {
+		// Unknown types are not length-gated.
+		return ""
+	}
+	if len(value) < minLen {
+		return fmt.Sprintf("value is %d chars, expected at least %d for %s", len(value), minLen, authType)
+	}
+	return ""
+}
+
+// trimmedLen returns the length of value after trimming ASCII spaces,
+// tabs, newlines, and carriage returns. Used to detect paste errors.
+func trimmedLen(value string) int {
+	start, end := 0, len(value)
+	for start < end && isSpace(value[start]) {
+		start++
+	}
+	for end > start && isSpace(value[end-1]) {
+		end--
+	}
+	return end - start
+}
+
+func isSpace(b byte) bool {
+	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
+}
+
+// displayType returns a stable display string for the auth type field
+// when the manifest's own value is empty. "none" and "" both render as
+// "none" so the table is consistent.
+func displayType(authType string) string {
+	if authType == "" {
+		return "none"
+	}
+	return authType
+}
diff --git a/internal/authdoctor/classify_test.go b/internal/authdoctor/classify_test.go
new file mode 100644
index 00000000..6e334108
--- /dev/null
+++ b/internal/authdoctor/classify_test.go
@@ -0,0 +1,194 @@
+package authdoctor
+
+import (
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
+)
+
+func envFrom(m map[string]string) getEnv {
+	return func(k string) string {
+		return m[k]
+	}
+}
+
+func TestClassifyNilManifest(t *testing.T) {
+	findings := Classify("hubspot", nil, envFrom(nil))
+	if len(findings) != 1 {
+		t.Fatalf("want 1 finding, got %d", len(findings))
+	}
+	if findings[0].Status != StatusUnknown {
+		t.Errorf("want StatusUnknown, got %q", findings[0].Status)
+	}
+}
+
+func TestClassifyNoAuth(t *testing.T) {
+	cases := []struct {
+		name string
+		auth pipeline.ManifestAuth
+	}{
+		{"type=none", pipeline.ManifestAuth{Type: "none"}},
+		{"type empty", pipeline.ManifestAuth{}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			m := &pipeline.ToolsManifest{Auth: tc.auth}
+			findings := Classify("hackernews", m, envFrom(nil))
+			if len(findings) != 1 {
+				t.Fatalf("want 1 finding, got %d", len(findings))
+			}
+			if findings[0].Status != StatusNoAuth {
+				t.Errorf("want StatusNoAuth, got %q", findings[0].Status)
+			}
+		})
+	}
+}
+
+func TestClassifyAuthTypeWithoutEnvVars(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{Type: "api_key"},
+	}
+	findings := Classify("mystery", m, envFrom(nil))
+	if len(findings) != 1 {
+		t.Fatalf("want 1 finding, got %d", len(findings))
+	}
+	if findings[0].Status != StatusUnknown {
+		t.Errorf("want StatusUnknown when type is declared but env_vars empty, got %q", findings[0].Status)
+	}
+}
+
+func TestClassifyEnvVarSetOK(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "api_key",
+			EnvVars: []string{"HUBSPOT_ACCESS_TOKEN"},
+		},
+	}
+	env := envFrom(map[string]string{"HUBSPOT_ACCESS_TOKEN": "pat-xxxxxxxxxxxx"})
+	findings := Classify("hubspot", m, env)
+	if len(findings) != 1 {
+		t.Fatalf("want 1 finding, got %d", len(findings))
+	}
+	f := findings[0]
+	if f.Status != StatusOK {
+		t.Errorf("want StatusOK, got %q (reason=%q)", f.Status, f.Reason)
+	}
+	if f.Fingerprint != "pat-..." {
+		t.Errorf("want fingerprint %q, got %q", "pat-...", f.Fingerprint)
+	}
+	if f.EnvVar != "HUBSPOT_ACCESS_TOKEN" {
+		t.Errorf("env var not carried through: %q", f.EnvVar)
+	}
+}
+
+func TestClassifyEnvVarUnset(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "api_key",
+			EnvVars: []string{"ESPN_KEY"},
+		},
+	}
+	findings := Classify("espn", m, envFrom(nil))
+	if len(findings) != 1 {
+		t.Fatalf("want 1 finding, got %d", len(findings))
+	}
+	if findings[0].Status != StatusNotSet {
+		t.Errorf("want StatusNotSet, got %q", findings[0].Status)
+	}
+	if findings[0].Fingerprint != "" {
+		t.Errorf("fingerprint should be empty for unset, got %q", findings[0].Fingerprint)
+	}
+}
+
+func TestClassifyEnvVarSuspiciousShortAPIKey(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "api_key",
+			EnvVars: []string{"ESPN_KEY"},
+		},
+	}
+	env := envFrom(map[string]string{"ESPN_KEY": "abc"})
+	findings := Classify("espn", m, env)
+	if len(findings) != 1 {
+		t.Fatalf("want 1 finding, got %d", len(findings))
+	}
+	f := findings[0]
+	if f.Status != StatusSuspicious {
+		t.Errorf("want StatusSuspicious, got %q", f.Status)
+	}
+	if f.Reason == "" {
+		t.Error("suspicious finding should carry a reason")
+	}
+	if f.Fingerprint == "" {
+		t.Error("suspicious finding should still carry a fingerprint")
+	}
+}
+
+func TestClassifyEnvVarSuspiciousShortBearerToken(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "bearer_token",
+			EnvVars: []string{"DUB_TOKEN"},
+		},
+	}
+	// 12 chars, min for bearer_token is 20
+	env := envFrom(map[string]string{"DUB_TOKEN": "short_value1"})
+	findings := Classify("dub", m, env)
+	if findings[0].Status != StatusSuspicious {
+		t.Errorf("want StatusSuspicious for short bearer token, got %q", findings[0].Status)
+	}
+}
+
+func TestClassifyEnvVarSuspiciousSurroundingWhitespace(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "api_key",
+			EnvVars: []string{"HUBSPOT_ACCESS_TOKEN"},
+		},
+	}
+	env := envFrom(map[string]string{"HUBSPOT_ACCESS_TOKEN": "  pat-well-formed-value  "})
+	findings := Classify("hubspot", m, env)
+	f := findings[0]
+	if f.Status != StatusSuspicious {
+		t.Errorf("want StatusSuspicious for wrapped whitespace, got %q", f.Status)
+	}
+	if f.Reason == "" {
+		t.Error("whitespace finding should carry a reason")
+	}
+}
+
+func TestClassifyUnknownAuthTypePassesLengthCheck(t *testing.T) {
+	// Unknown types are not length-gated; any non-empty value is OK.
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "composed",
+			EnvVars: []string{"DOMINOS_TOKEN"},
+		},
+	}
+	env := envFrom(map[string]string{"DOMINOS_TOKEN": "xy"})
+	findings := Classify("dominos", m, env)
+	if findings[0].Status != StatusOK {
+		t.Errorf("unknown auth types should not be length-gated, got %q", findings[0].Status)
+	}
+}
+
+func TestClassifyComposedMultipleEnvVarsMixed(t *testing.T) {
+	m := &pipeline.ToolsManifest{
+		Auth: pipeline.ManifestAuth{
+			Type:    "composed",
+			EnvVars: []string{"COOKIE_A", "COOKIE_B"},
+		},
+	}
+	env := envFrom(map[string]string{"COOKIE_A": "abcdef12345"})
+	findings := Classify("pagliacci", m, env)
+	if len(findings) != 2 {
+		t.Fatalf("want 2 findings (one per env var), got %d", len(findings))
+	}
+	// Findings are in manifest order
+	if findings[0].Status != StatusOK {
+		t.Errorf("COOKIE_A should be OK, got %q", findings[0].Status)
+	}
+	if findings[1].Status != StatusNotSet {
+		t.Errorf("COOKIE_B should be NotSet, got %q", findings[1].Status)
+	}
+}
diff --git a/internal/authdoctor/fingerprint.go b/internal/authdoctor/fingerprint.go
new file mode 100644
index 00000000..b6297530
--- /dev/null
+++ b/internal/authdoctor/fingerprint.go
@@ -0,0 +1,47 @@
+package authdoctor
+
+import (
+	"strings"
+	"unicode"
+)
+
+// fingerprintLen is the number of characters included in the fingerprint.
+// Four characters is enough to distinguish token families (pat-, xoxb-,
+// lin_, dub_) without narrowing to a user-specific secret.
+const fingerprintLen = 4
+
+// Fingerprint returns a redacted preview of a token value: the first
+// few characters followed by an ellipsis. Non-printable and control
+// characters are replaced with "?" so a malicious terminal-escape in
+// a token value cannot rewrite the user's terminal.
+//
+// Empty input returns an empty string.
+func Fingerprint(value string) string {
+	if value == "" {
+		return ""
+	}
+
+	runes := []rune(value)
+	var b strings.Builder
+	b.Grow(fingerprintLen + 3)
+
+	limit := fingerprintLen
+	if len(runes) < limit {
+		limit = len(runes)
+	}
+
+	for i := 0; i < limit; i++ {
+		r := runes[i]
+		if !unicode.IsPrint(r) {
+			b.WriteRune('?')
+			continue
+		}
+		b.WriteRune(r)
+	}
+
+	if len(runes) > fingerprintLen {
+		b.WriteString("...")
+	}
+
+	return b.String()
+}
diff --git a/internal/authdoctor/fingerprint_test.go b/internal/authdoctor/fingerprint_test.go
new file mode 100644
index 00000000..fe3f825c
--- /dev/null
+++ b/internal/authdoctor/fingerprint_test.go
@@ -0,0 +1,29 @@
+package authdoctor
+
+import "testing"
+
+func TestFingerprint(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  string
+	}{
+		{"empty", "", ""},
+		{"short value no ellipsis", "abc", "abc"},
+		{"exact threshold no ellipsis", "abcd", "abcd"},
+		{"longer than threshold gets ellipsis", "pat-abcdef123456", "pat-..."},
+		{"github-style pat", "ghp_abcdef1234567890", "ghp_..."},
+		{"slack bot token", "xoxb-1234-5678-abcdef", "xoxb..."},
+		{"control chars replaced", "\x1b[31mred", "?[31..."},
+		{"tab and newline replaced", "a\tb\ncde", "a?b?..."},
+		{"unicode printable kept", "résumé-token-value", "résu..."},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := Fingerprint(tc.input)
+			if got != tc.want {
+				t.Errorf("Fingerprint(%q) = %q, want %q", tc.input, got, tc.want)
+			}
+		})
+	}
+}
diff --git a/internal/authdoctor/render.go b/internal/authdoctor/render.go
new file mode 100644
index 00000000..59ee5701
--- /dev/null
+++ b/internal/authdoctor/render.go
@@ -0,0 +1,110 @@
+package authdoctor
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"strings"
+)
+
+// RenderJSON writes findings and a summary to w as indented JSON.
+func RenderJSON(w io.Writer, findings []Finding) error {
+	payload := struct {
+		Summary  Summary   `json:"summary"`
+		Findings []Finding `json:"findings"`
+	}{
+		Summary:  Summarize(findings),
+		Findings: findings,
+	}
+	enc := json.NewEncoder(w)
+	enc.SetIndent("", "  ")
+	return enc.Encode(payload)
+}
+
+// RenderTable writes findings as a human-friendly table to w.
+// Column widths adjust to the widest entry in each column with sensible
+// minimums. An empty findings slice prints a "no CLIs found" message.
+func RenderTable(w io.Writer, findings []Finding) error {
+	if len(findings) == 0 {
+		if _, err := fmt.Fprintln(w, "No printed CLIs with auth manifests found in ~/printing-press/library/."); err != nil {
+			return err
+		}
+		return nil
+	}
+
+	headers := []string{"API", "Type", "Env Var", "Status", "Value", "Notes"}
+	rows := make([][]string, 0, len(findings))
+	for _, f := range findings {
+		rows = append(rows, []string{
+			f.API,
+			orDash(f.Type),
+			orDash(f.EnvVar),
+			string(f.Status),
+			orDash(f.Fingerprint),
+			f.Reason,
+		})
+	}
+
+	widths := make([]int, len(headers))
+	for i, h := range headers {
+		widths[i] = len(h)
+	}
+	for _, row := range rows {
+		for i, cell := range row {
+			if len(cell) > widths[i] {
+				widths[i] = len(cell)
+			}
+		}
+	}
+
+	if err := writeRow(w, widths, headers); err != nil {
+		return err
+	}
+	sep := make([]string, len(headers))
+	for i := range sep {
+		sep[i] = strings.Repeat("-", widths[i])
+	}
+	if err := writeRow(w, widths, sep); err != nil {
+		return err
+	}
+	for _, row := range rows {
+		if err := writeRow(w, widths, row); err != nil {
+			return err
+		}
+	}
+
+	s := Summarize(findings)
+	if _, err := fmt.Fprintf(w, "\nSummary: %d ok, %d suspicious, %d not set, %d no auth, %d unknown\n",
+		s.OK, s.Suspicious, s.NotSet, s.NoAuth, s.Unknown); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// writeRow prints one table row with left-aligned cells padded to widths.
+func writeRow(w io.Writer, widths []int, cells []string) error {
+	var b strings.Builder
+	for i, cell := range cells {
+		if i > 0 {
+			b.WriteString("  ")
+		}
+		b.WriteString(cell)
+		if i < len(cells)-1 {
+			pad := widths[i] - len(cell)
+			if pad > 0 {
+				b.WriteString(strings.Repeat(" ", pad))
+			}
+		}
+	}
+	b.WriteString("\n")
+	_, err := io.WriteString(w, b.String())
+	return err
+}
+
+func orDash(s string) string {
+	if s == "" {
+		return "-"
+	}
+	return s
+}
diff --git a/internal/authdoctor/render_test.go b/internal/authdoctor/render_test.go
new file mode 100644
index 00000000..495ecfb5
--- /dev/null
+++ b/internal/authdoctor/render_test.go
@@ -0,0 +1,73 @@
+package authdoctor
+
+import (
+	"bytes"
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+func TestRenderTableEmpty(t *testing.T) {
+	var buf bytes.Buffer
+	if err := RenderTable(&buf, nil); err != nil {
+		t.Fatalf("RenderTable: %v", err)
+	}
+	out := buf.String()
+	if !strings.Contains(out, "No printed CLIs") {
+		t.Errorf("want empty message, got %q", out)
+	}
+}
+
+func TestRenderTableRows(t *testing.T) {
+	findings := []Finding{
+		{API: "dub", Type: "bearer_token", EnvVar: "DUB_TOKEN", Status: StatusSuspicious, Fingerprint: "abc", Reason: "too short"},
+		{API: "hubspot", Type: "api_key", EnvVar: "HUBSPOT_ACCESS_TOKEN", Status: StatusOK, Fingerprint: "pat-..."},
+		{API: "hackernews", Type: "none", Status: StatusNoAuth},
+	}
+	var buf bytes.Buffer
+	if err := RenderTable(&buf, findings); err != nil {
+		t.Fatalf("RenderTable: %v", err)
+	}
+	out := buf.String()
+
+	// Header present
+	if !strings.Contains(out, "API") || !strings.Contains(out, "Env Var") {
+		t.Errorf("missing header in output:\n%s", out)
+	}
+	// Row data present
+	if !strings.Contains(out, "dub") || !strings.Contains(out, "DUB_TOKEN") {
+		t.Errorf("missing dub row:\n%s", out)
+	}
+	// Summary line present
+	if !strings.Contains(out, "Summary:") {
+		t.Errorf("missing summary line:\n%s", out)
+	}
+	if !strings.Contains(out, "1 ok") || !strings.Contains(out, "1 suspicious") || !strings.Contains(out, "1 no auth") {
+		t.Errorf("summary counts wrong:\n%s", out)
+	}
+}
+
+func TestRenderJSONShape(t *testing.T) {
+	findings := []Finding{
+		{API: "dub", Type: "bearer_token", EnvVar: "DUB_TOKEN", Status: StatusSuspicious, Fingerprint: "abc", Reason: "too short"},
+		{API: "hackernews", Type: "none", Status: StatusNoAuth},
+	}
+	var buf bytes.Buffer
+	if err := RenderJSON(&buf, findings); err != nil {
+		t.Fatalf("RenderJSON: %v", err)
+	}
+
+	var payload struct {
+		Summary  Summary   `json:"summary"`
+		Findings []Finding `json:"findings"`
+	}
+	if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
+		t.Fatalf("parse JSON output: %v\noutput: %s", err, buf.String())
+	}
+	if payload.Summary.Suspicious != 1 || payload.Summary.NoAuth != 1 {
+		t.Errorf("summary wrong: %+v", payload.Summary)
+	}
+	if len(payload.Findings) != 2 {
+		t.Errorf("findings count wrong: %d", len(payload.Findings))
+	}
+}
diff --git a/internal/authdoctor/scan.go b/internal/authdoctor/scan.go
new file mode 100644
index 00000000..21c2f657
--- /dev/null
+++ b/internal/authdoctor/scan.go
@@ -0,0 +1,91 @@
+// Package authdoctor scans the local printing-press library and reports
+// the env-var status of every installed printed CLI. It is the data layer
+// for the `printing-press auth doctor` command.
+//
+// The scanner reads each API's tools-manifest.json (the same file megamcp
+// consumes) and classifies each declared env var as ok, suspicious,
+// not-set, or no-auth. No network calls. No writes to disk.
+package authdoctor
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
+)
+
+// Scan inspects every installed printed CLI under the published library
+// root and returns Findings ordered by API slug. A missing library
+// directory is not an error; it returns an empty slice. Unreadable
+// manifest files produce StatusUnknown findings rather than failing the
+// whole scan.
+//
+// The environment is read via os.Getenv.
+func Scan() ([]Finding, error) {
+	return ScanAt(pipeline.PublishedLibraryRoot(), os.Getenv)
+}
+
+// ScanAt is the testable form of Scan. It accepts an explicit library
+// root directory and an env lookup function so tests can seed a
+// synthetic library and environment.
+func ScanAt(libraryRoot string, env getEnv) ([]Finding, error) {
+	dirEntries, err := os.ReadDir(libraryRoot)
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return []Finding{}, nil
+		}
+		return nil, fmt.Errorf("reading library root %q: %w", libraryRoot, err)
+	}
+
+	var findings []Finding
+	for _, de := range dirEntries {
+		if !de.IsDir() {
+			continue
+		}
+		slug := de.Name()
+		manifestPath := filepath.Join(libraryRoot, slug, pipeline.ToolsManifestFilename)
+
+		manifest, loadErr := loadManifest(manifestPath)
+		switch {
+		case loadErr != nil && errors.Is(loadErr, os.ErrNotExist):
+			// Directory exists but no manifest file. Skip — this is a
+			// CLI generated before the manifest convention landed.
+			continue
+		case loadErr != nil:
+			findings = append(findings, Finding{
+				API:    slug,
+				Status: StatusUnknown,
+				Reason: fmt.Sprintf("manifest parse error: %v", loadErr),
+			})
+			continue
+		}
+
+		findings = append(findings, Classify(slug, manifest, env)...)
+	}
+
+	sort.Slice(findings, func(i, j int) bool {
+		if findings[i].API != findings[j].API {
+			return findings[i].API < findings[j].API
+		}
+		return findings[i].EnvVar < findings[j].EnvVar
+	})
+
+	return findings, nil
+}
+
+// loadManifest reads and parses a tools-manifest.json file.
+func loadManifest(path string) (*pipeline.ToolsManifest, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var m pipeline.ToolsManifest
+	if err := json.Unmarshal(data, &m); err != nil {
+		return nil, fmt.Errorf("invalid JSON: %w", err)
+	}
+	return &m, nil
+}
diff --git a/internal/authdoctor/scan_test.go b/internal/authdoctor/scan_test.go
new file mode 100644
index 00000000..9a9deee9
--- /dev/null
+++ b/internal/authdoctor/scan_test.go
@@ -0,0 +1,180 @@
+package authdoctor
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
+)
+
+func writeManifest(t *testing.T, dir string, m pipeline.ToolsManifest) {
+	t.Helper()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir %s: %v", dir, err)
+	}
+	data, err := json.MarshalIndent(m, "", "  ")
+	if err != nil {
+		t.Fatalf("marshal manifest: %v", err)
+	}
+	path := filepath.Join(dir, pipeline.ToolsManifestFilename)
+	if err := os.WriteFile(path, data, 0o644); err != nil {
+		t.Fatalf("write %s: %v", path, err)
+	}
+}
+
+func TestScanAtMissingRoot(t *testing.T) {
+	findings, err := ScanAt(filepath.Join(t.TempDir(), "nonexistent"), envFrom(nil))
+	if err != nil {
+		t.Fatalf("missing root should not error, got %v", err)
+	}
+	if len(findings) != 0 {
+		t.Errorf("want empty findings for missing root, got %d", len(findings))
+	}
+}
+
+func TestScanAtFullMatrix(t *testing.T) {
+	root := t.TempDir()
+
+	writeManifest(t, filepath.Join(root, "hubspot"), pipeline.ToolsManifest{
+		APIName: "HubSpot",
+		Auth: pipeline.ManifestAuth{
+			Type:    "api_key",
+			EnvVars: []string{"HUBSPOT_ACCESS_TOKEN"},
+		},
+	})
+	writeManifest(t, filepath.Join(root, "espn"), pipeline.ToolsManifest{
+		APIName: "ESPN",
+		Auth: pipeline.ManifestAuth{
+			Type:    "api_key",
+			EnvVars: []string{"ESPN_KEY"},
+		},
+	})
+	writeManifest(t, filepath.Join(root, "dub"), pipeline.ToolsManifest{
+		APIName: "Dub",
+		Auth: pipeline.ManifestAuth{
+			Type:    "bearer_token",
+			EnvVars: []string{"DUB_TOKEN"},
+		},
+	})
+	writeManifest(t, filepath.Join(root, "hackernews"), pipeline.ToolsManifest{
+		APIName: "Hacker News",
+		Auth:    pipeline.ManifestAuth{Type: "none"},
+	})
+
+	env := envFrom(map[string]string{
+		"HUBSPOT_ACCESS_TOKEN": "pat-xxxxxxxxxxxxxxxxxxxx",
+		// ESPN_KEY unset -> not_set
+		"DUB_TOKEN": "abc", // too short -> suspicious
+		// hackernews has no auth -> no_auth
+	})
+
+	findings, err := ScanAt(root, env)
+	if err != nil {
+		t.Fatalf("ScanAt: %v", err)
+	}
+
+	// Results sorted by API slug.
+	if len(findings) != 4 {
+		t.Fatalf("want 4 findings, got %d: %+v", len(findings), findings)
+	}
+
+	// dub first alphabetically
+	if findings[0].API != "dub" || findings[0].Status != StatusSuspicious {
+		t.Errorf("findings[0] = %+v, want dub/suspicious", findings[0])
+	}
+	if findings[1].API != "espn" || findings[1].Status != StatusNotSet {
+		t.Errorf("findings[1] = %+v, want espn/not_set", findings[1])
+	}
+	if findings[2].API != "hackernews" || findings[2].Status != StatusNoAuth {
+		t.Errorf("findings[2] = %+v, want hackernews/no_auth", findings[2])
+	}
+	if findings[3].API != "hubspot" || findings[3].Status != StatusOK {
+		t.Errorf("findings[3] = %+v, want hubspot/ok", findings[3])
+	}
+
+	s := Summarize(findings)
+	if s.OK != 1 || s.Suspicious != 1 || s.NotSet != 1 || s.NoAuth != 1 {
+		t.Errorf("summary = %+v, want 1/1/1/1", s)
+	}
+}
+
+func TestScanAtSkipsDirectoryWithoutManifest(t *testing.T) {
+	root := t.TempDir()
+
+	// Legacy CLI directory with no manifest file.
+	if err := os.MkdirAll(filepath.Join(root, "legacy-cli"), 0o755); err != nil {
+		t.Fatalf("mkdir legacy: %v", err)
+	}
+	// A real CLI with a manifest.
+	writeManifest(t, filepath.Join(root, "hubspot"), pipeline.ToolsManifest{
+		APIName: "HubSpot",
+		Auth:    pipeline.ManifestAuth{Type: "none"},
+	})
+
+	findings, err := ScanAt(root, envFrom(nil))
+	if err != nil {
+		t.Fatalf("ScanAt: %v", err)
+	}
+	if len(findings) != 1 || findings[0].API != "hubspot" {
+		t.Errorf("legacy dir should be skipped silently; findings=%+v", findings)
+	}
+}
+
+func TestScanAtCorruptManifestReportedAsUnknown(t *testing.T) {
+	root := t.TempDir()
+
+	// Well-formed manifest for one CLI.
+	writeManifest(t, filepath.Join(root, "hubspot"), pipeline.ToolsManifest{
+		APIName: "HubSpot",
+		Auth:    pipeline.ManifestAuth{Type: "none"},
+	})
+
+	// Corrupt manifest for another.
+	corruptDir := filepath.Join(root, "broken")
+	if err := os.MkdirAll(corruptDir, 0o755); err != nil {
+		t.Fatalf("mkdir broken: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(corruptDir, pipeline.ToolsManifestFilename), []byte("{not json"), 0o644); err != nil {
+		t.Fatalf("write corrupt: %v", err)
+	}
+
+	findings, err := ScanAt(root, envFrom(nil))
+	if err != nil {
+		t.Fatalf("corrupt manifest should not fail whole scan: %v", err)
+	}
+	if len(findings) != 2 {
+		t.Fatalf("want 2 findings (one per CLI), got %d", len(findings))
+	}
+	// Sorted: broken before hubspot.
+	if findings[0].API != "broken" || findings[0].Status != StatusUnknown {
+		t.Errorf("findings[0] = %+v, want broken/unknown", findings[0])
+	}
+	if findings[0].Reason == "" {
+		t.Error("corrupt manifest finding should carry a reason")
+	}
+	if findings[1].API != "hubspot" || findings[1].Status != StatusNoAuth {
+		t.Errorf("findings[1] = %+v, want hubspot/no_auth", findings[1])
+	}
+}
+
+func TestScanAtIgnoresNonDirEntries(t *testing.T) {
+	root := t.TempDir()
+	// Stray file in library root should be ignored.
+	if err := os.WriteFile(filepath.Join(root, ".DS_Store"), []byte("noise"), 0o644); err != nil {
+		t.Fatalf("write stray: %v", err)
+	}
+	writeManifest(t, filepath.Join(root, "hubspot"), pipeline.ToolsManifest{
+		APIName: "HubSpot",
+		Auth:    pipeline.ManifestAuth{Type: "none"},
+	})
+
+	findings, err := ScanAt(root, envFrom(nil))
+	if err != nil {
+		t.Fatalf("ScanAt: %v", err)
+	}
+	if len(findings) != 1 {
+		t.Errorf("want 1 finding (stray file ignored), got %d", len(findings))
+	}
+}
diff --git a/internal/authdoctor/types.go b/internal/authdoctor/types.go
new file mode 100644
index 00000000..05fdffb3
--- /dev/null
+++ b/internal/authdoctor/types.go
@@ -0,0 +1,54 @@
+package authdoctor
+
+// Status classifies a single API's auth credential state.
+type Status string
+
+const (
+	StatusOK         Status = "ok"
+	StatusSuspicious Status = "suspicious"
+	StatusNotSet     Status = "not_set"
+	StatusNoAuth     Status = "no_auth"
+	StatusUnknown    Status = "unknown"
+)
+
+// Finding is one row of the auth doctor table. Each installed API may
+// produce one or more Findings (one per declared env var; one with
+// StatusNoAuth when the manifest declares no auth).
+type Finding struct {
+	API         string `json:"api"`
+	Type        string `json:"type"`
+	EnvVar      string `json:"env_var,omitempty"`
+	Status      Status `json:"status"`
+	Fingerprint string `json:"fingerprint,omitempty"`
+	Reason      string `json:"reason,omitempty"`
+}
+
+// Summary is the count of findings by status, suitable for the summary
+// line of the table renderer and the "summary" field in JSON output.
+type Summary struct {
+	OK         int `json:"ok"`
+	Suspicious int `json:"suspicious"`
+	NotSet     int `json:"not_set"`
+	NoAuth     int `json:"no_auth"`
+	Unknown    int `json:"unknown"`
+}
+
+// Summarize counts findings by status.
+func Summarize(findings []Finding) Summary {
+	var s Summary
+	for _, f := range findings {
+		switch f.Status {
+		case StatusOK:
+			s.OK++
+		case StatusSuspicious:
+			s.Suspicious++
+		case StatusNotSet:
+			s.NotSet++
+		case StatusNoAuth:
+			s.NoAuth++
+		case StatusUnknown:
+			s.Unknown++
+		}
+	}
+	return s
+}
diff --git a/internal/cli/auth_doctor_cmd.go b/internal/cli/auth_doctor_cmd.go
new file mode 100644
index 00000000..63c714bb
--- /dev/null
+++ b/internal/cli/auth_doctor_cmd.go
@@ -0,0 +1,53 @@
+package cli
+
+import (
+	"fmt"
+
+	"github.com/mvanhorn/cli-printing-press/internal/authdoctor"
+	"github.com/spf13/cobra"
+)
+
+func newAuthCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "auth",
+		Short: "Inspect auth state across installed printed CLIs",
+		Long: `Auth tooling for the local printing-press library.
+
+Subcommands inspect env-var status for every installed printed CLI
+under ~/printing-press/library/ using each CLI's tools-manifest.json.`,
+	}
+	cmd.AddCommand(newAuthDoctorCmd())
+	return cmd
+}
+
+func newAuthDoctorCmd() *cobra.Command {
+	var asJSON bool
+
+	cmd := &cobra.Command{
+		Use:   "doctor",
+		Short: "Report env-var status for every installed printed CLI",
+		Long: `Scans ~/printing-press/library/<api>/tools-manifest.json for every
+installed printed CLI and reports whether its declared env vars are set,
+unset, or suspicious. Fingerprints show the first four characters of each
+set value (never the full token).
+
+Exit 0 even when findings include 'not set' or 'suspicious' — this command
+is diagnostic, not gating. Exit 5 only if the scan itself fails.`,
+		Example: `  printing-press auth doctor
+  printing-press auth doctor --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			findings, err := authdoctor.Scan()
+			if err != nil {
+				return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("scanning library: %w", err)}
+			}
+
+			if asJSON {
+				return authdoctor.RenderJSON(cmd.OutOrStdout(), findings)
+			}
+			return authdoctor.RenderTable(cmd.OutOrStdout(), findings)
+		},
+	}
+
+	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+	return cmd
+}
diff --git a/internal/cli/auth_doctor_cmd_test.go b/internal/cli/auth_doctor_cmd_test.go
new file mode 100644
index 00000000..ed7f9bf7
--- /dev/null
+++ b/internal/cli/auth_doctor_cmd_test.go
@@ -0,0 +1,66 @@
+package cli
+
+import (
+	"bytes"
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+func TestAuthDoctorCmdJSONFlag(t *testing.T) {
+	cmd := newAuthDoctorCmd()
+	var out bytes.Buffer
+	cmd.SetOut(&out)
+	cmd.SetErr(&out)
+	cmd.SetArgs([]string{"--json"})
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("execute: %v", err)
+	}
+
+	// Output must be parseable JSON and contain a summary key.
+	var payload map[string]any
+	if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
+		t.Fatalf("output not valid JSON: %v\n%s", err, out.String())
+	}
+	if _, ok := payload["summary"]; !ok {
+		t.Errorf("JSON output missing 'summary' key: %s", out.String())
+	}
+	if _, ok := payload["findings"]; !ok {
+		t.Errorf("JSON output missing 'findings' key: %s", out.String())
+	}
+}
+
+func TestAuthDoctorCmdTableFallback(t *testing.T) {
+	cmd := newAuthDoctorCmd()
+	var out bytes.Buffer
+	cmd.SetOut(&out)
+	cmd.SetErr(&out)
+	cmd.SetArgs([]string{})
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("execute: %v", err)
+	}
+
+	output := out.String()
+	// Either the empty-library message or a table with a summary line.
+	// Both are acceptable — depends on whether the developer has
+	// ~/printing-press/library/ populated locally.
+	if !strings.Contains(output, "No printed CLIs") && !strings.Contains(output, "Summary:") {
+		t.Errorf("expected either empty message or summary line; got:\n%s", output)
+	}
+}
+
+func TestAuthCmdHasDoctorSubcommand(t *testing.T) {
+	cmd := newAuthCmd()
+	var found bool
+	for _, sub := range cmd.Commands() {
+		if sub.Name() == "doctor" {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Error("auth command should have a 'doctor' subcommand")
+	}
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index e96d6dc5..71f7eb7c 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -52,6 +52,7 @@ func Execute() error {
 	rootCmd.AddCommand(newCrowdSniffCmd())
 	rootCmd.AddCommand(newCatalogCmd())
 	rootCmd.AddCommand(newLibraryCmd())
+	rootCmd.AddCommand(newAuthCmd())
 	rootCmd.AddCommand(newPublishCmd())
 	rootCmd.AddCommand(newPolishCmd())
 	rootCmd.AddCommand(newWorkflowVerifyCmd())

← 23084c76 refactor(cli): rename sniff to browser-sniff (#225)  ·  back to Cli Printing Press  ·  feat(cli): support extra_commands: in spec.yaml for hand-wri 84043f3a →