Skip to content

refactor: brand CommandOutput, unify commands as generators, remove stdout plumbing#416

Open
BYK wants to merge 14 commits intomainfrom
feat/generator-commands
Open

refactor: brand CommandOutput, unify commands as generators, remove stdout plumbing#416
BYK wants to merge 14 commits intomainfrom
feat/generator-commands

Conversation

@BYK
Copy link
Member

@BYK BYK commented Mar 12, 2026

Summary

Three changes to the command framework output system:

1. Unify all command functions as async generators

Every command func is now an async *func generator. This is the prerequisite for streaming output — commands yield values instead of writing to stdout directly.

  • Commands that had async func are converted to async *func
  • Void generators (e.g. auth/login) get a useYield lint suppression until they are migrated to yield pattern in Phase 6b

2. Brand CommandOutput with Symbol discriminant

Replace duck-typing ("data" in value) with a COMMAND_OUTPUT_BRAND Symbol on the CommandOutput type. This prevents false positives from raw API responses that happen to have a data property.

  • New commandOutput<T>(data: T) factory function creates branded values
  • isCommandOutput() checks the Symbol instead of structural shape
  • All 24 command files migrated to use commandOutput() helper

3. Move hints from yield to generator return value

Hints (footer text like "Detected from .env.local") are no longer part of the yielded CommandOutput. Instead, generators return { hint } after their final yield.

  • New CommandReturn type: { hint?: string }
  • Wrapper uses manual .next() iteration instead of for-await-of to capture the return value
  • renderCommandOutput no longer handles hints — the wrapper calls writeFooter() after the generator completes
  • Hints are suppressed in JSON mode (JSON output is self-contained)

4. Remove stdout/stderr plumbing from commands

Commands no longer receive or pass stdout/stderr Writer references. Interactive and diagnostic output now routes through the project's consola logger (→ stderr), keeping stdout reserved for structured command output.

  • HandlerContext and DispatchOptions no longer carry stdout
  • runInteractiveLogin uses logger instead of Writer params
  • Follow-mode banners, diagnostics, and QR code display all use logger
  • displayTraceLogsformatTraceLogs (returns string instead of writing)
  • New formatFooter() helper extracted from writeFooter()

Remaining stdout usage (intentional):

  • auth/token: raw stdout for pipe compatibility (sentry auth token | pbcopy)
  • help.ts: help text to stdout (like git --help)
  • trace/logs.ts: process.stdout.write (pending OutputConfig migration)

Pattern

// Before:
async func(this: SentryContext, flags) {
  const { stdout } = this;
  const result = await fetchData();
  stdout.write(formatResult(result));
}

// After:
async *func(this: SentryContext, flags) {
  const result = await fetchData();
  yield commandOutput(result);
  return { hint: "some footer" };
}

Test plan

  • 1597 tests pass, 0 fail, 15910 assertions across 53 files
  • Typecheck clean, lint clean (375 files)

@github-actions
Copy link
Contributor

github-actions bot commented Mar 12, 2026

Semver Impact of This PR

🟢 Patch (bug fixes)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

Init

  • Add --team flag to relay team selection to project creation by MathurAditya724 in #403
  • Enforce canonical feature display order by betegon in #388
  • Accept multiple delimiter formats for --features flag by betegon in #386
  • Add git safety checks before wizard modifies files by betegon in #379
  • Add experimental warning before wizard runs by betegon in #378
  • Add init command for guided Sentry project setup by betegon in #283

Issue List

  • Auto-compact when table exceeds terminal height by BYK in #395
  • Redesign table to match Sentry web UI by BYK in #372

Other

  • (auth) Allow re-authentication without manual logout by BYK in #417
  • (trial) Auto-prompt for Seer trial + sentry trial list/start commands by BYK in #399
  • Support SENTRY_HOST as alias for SENTRY_URL by betegon in #409
  • Add --dry-run flag to mutating commands by BYK in #387
  • Return-based output with OutputConfig on buildCommand by BYK in #380
  • Add --fields flag for context-window-friendly JSON output by BYK in #373
  • Magic @ selectors (@latest, @most_frequent) for issue commands by BYK in #371
  • Input hardening against agent hallucinations by BYK in #370
  • Add response caching for read-only API calls by BYK in #330

Bug Fixes 🐛

Dsn

Init

  • Make URLs clickable with OSC 8 terminal hyperlinks by MathurAditya724 in #423
  • Remove implementation detail from help text by betegon in #385
  • Truncate uncommitted file list to first 5 entries by MathurAditya724 in #381

Other

  • (api) Convert --data to query params for GET requests by BYK in #383
  • (docs) Remove double borders and fix column alignment on landing page tables by betegon in #369
  • (trace) Show span IDs in trace view and fix event_id mapping by betegon in #400
  • Show human-friendly names in trial list and surface plan trials by BYK in #412
  • Add trace ID validation to trace view + UUID dash-stripping by BYK in #375

Documentation 📚

  • Update credential storage docs and remove stale config.json references by betegon in #408

Internal Changes 🔧

Init

  • Remove --force flag by betegon in #377
  • Remove dead determine-pm step label by betegon in #374

Tests

  • Consolidate unit tests subsumed by property tests by BYK in #422
  • Remove redundant and low-value tests by BYK in #418

Other

  • (log/list) Convert non-follow paths to return CommandOutput by BYK in #410
  • Brand CommandOutput, unify commands as generators, remove stdout plumbing by BYK in #416
  • Convert list command handlers to return data instead of writing stdout by BYK in #404
  • Split api-client.ts into focused domain modules by BYK in #405
  • Migrate non-streaming commands to CommandOutput with markdown rendering by BYK in #398
  • Convert Tier 2-3 commands to return-based output and consola by BYK in #394
  • Convert remaining Tier 1 commands to return-based output by BYK in #382
  • Converge Tier 1 commands to writeOutput helper by BYK in #376

Other

  • Minify JSON on read and pretty-print on write in init local ops by MathurAditya724 in #396

🤖 This preview updates automatically when you update the PR.

@BYK BYK force-pushed the feat/generator-commands branch 2 times, most recently from 578fc7a to f111da6 Compare March 13, 2026 12:59
@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Codecov Results 📊

111 passed | Total: 111 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests
Passed Tests
Failed Tests
Skipped Tests

✨ No test changes detected

All tests are passing successfully.

✅ Patch coverage is 93.84%. Project has 1059 uncovered lines.
✅ Project coverage is 95.08%. Comparing base (base) to head (head).

Files with missing lines (8)
File Patch % Lines
interactive-login.ts 10.58% ⚠️ 93 Missing
clipboard.ts 4.60% ⚠️ 83 Missing
org-list.ts 95.34% ⚠️ 18 Missing
view.ts 94.09% ⚠️ 12 Missing
log.ts 97.21% ⚠️ 5 Missing
output.ts 96.88% ⚠️ 3 Missing
login.ts 98.53% ⚠️ 2 Missing
list-command.ts 98.94% ⚠️ 2 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
+ Coverage    95.03%    95.08%    +0.05%
==========================================
  Files          159       159         —
  Lines        21441     21534       +93
  Branches         0         0         —
==========================================
+ Hits         20376     20475       +99
- Misses        1065      1059        -6
- Partials         0         0         —

Generated by Codecov Action

@BYK BYK force-pushed the feat/generator-commands branch 2 times, most recently from bab6697 to db3aaaf Compare March 13, 2026 14:25
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unbranded yield silently drops plan trial output
    • Wrapped handlePlanTrial result with commandOutput() to add the required COMMAND_OUTPUT_BRAND symbol before yielding.

Create PR

Or push these changes by commenting:

@cursor push c47ed9e39f
Preview (c47ed9e39f)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -628,58 +628,39 @@
 
 ### Architecture
 
-<!-- lore:019cafbb-24ad-75a3-b037-5efbe6a1e85d -->
-* **DSN org prefix normalization in arg-parsing.ts**: Sentry DSN hosts encode org IDs as \`oNNNNN\` (e.g., \`o1081365.ingest.us.sentry.io\`). The Sentry API rejects the \`o\`-prefixed form. \`stripDsnOrgPrefix()\` in \`src/lib/arg-parsing.ts\` uses \`/^o(\d+)$/\` to strip the prefix — safe for slugs like \`organic\`. Applied in \`parseOrgProjectArg()\` and \`parseWithSlash()\`, covering all API call paths consuming \`parsed.org\`.
+<!-- lore:019cbeba-e4d3-748c-ad50-fe3c3d5c0a0d -->
+* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval.
 
-<!-- lore:019cb38b-e327-7ec5-8fb0-9e635b2bac48 -->
-* **GHCR versioned nightly tags for delta upgrade support**: GHCR nightly distribution uses three tag types: \`:nightly\` (rolling), \`:nightly-\<version>\` (immutable), \`:patch-\<version>\` (delta manifest). Delta patches use zig-bsdiff TRDIFF10 (zstd-compressed), ~50KB vs ~29MB full. Client bspatch via \`Bun.zstdDecompressSync()\`. N-1 patches only, full download fallback, SHA-256 verify, 60% size threshold. npm/Node excluded. Test mocks: use \`mockGhcrNightlyVersion()\` helper.
+<!-- lore:019cbaa2-e4a2-76c0-8f64-917a97ae20c5 -->
+* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr.
 
-<!-- lore:a1f33ceb-6116-4d29-b6d0-0dc9678e4341 -->
-* **Issue list auto-pagination beyond API's 100-item cap**: Sentry API silently caps \`limit\` at 100 per request. \`listIssuesAllPages()\` auto-paginates using Link headers, bounded by MAX\_PAGINATION\_PAGES (50). \`API\_MAX\_PER\_PAGE\` constant is shared across all paginated consumers. \`--limit\` means total results everywhere (max 1000, default 25). Org-all mode uses \`fetchOrgAllIssues()\`; explicit \`--cursor\` does single-page fetch to preserve cursor chain.
+<!-- lore:019cce8d-f2c5-726e-8a04-3f48caba45ec -->
+* **Input validation layer: src/lib/input-validation.ts guards CLI arg parsing**: Four validators in \`src/lib/input-validation.ts\` guard against agent-hallucinated inputs: \`rejectControlChars\` (ASCII < 0x20), \`rejectPreEncoded\` (%XX), \`validateResourceId\` (rejects ?, #, %, whitespace), \`validateEndpoint\` (rejects \`..\` traversal). Applied in \`parseSlashOrgProject\`, bare-slug path in \`parseOrgProjectArg\`, \`parseIssueArg\`, and \`normalizeEndpoint\` (api.ts). NOT applied in \`parseSlashSeparatedArg\` for no-slash plain IDs — those may contain structural separators (newlines for log view batch IDs) that callers split downstream. Validation targets user-facing parse boundaries only; env vars and DB cache values are trusted.
 
-<!-- lore:019cb950-9b7b-731a-9832-b7f6cfb6a6a2 -->
-* **Self-hosted OAuth device flow requires Sentry 26.1.0+ and SENTRY\_CLIENT\_ID**: Self-hosted OAuth device flow requires Sentry 26.1.0+ and both \`SENTRY\_URL\` and \`SENTRY\_CLIENT\_ID\` env vars. Users must create a public OAuth app in Settings → Developer Settings. The client ID is NOT optional for self-hosted. Fallback for older instances: \`sentry auth login --token\`. \`getSentryUrl()\` and \`getClientId()\` in \`src/lib/oauth.ts\` read lazily (not at module load) so URL parsing from arguments can set \`SENTRY\_URL\` after import.
+<!-- lore:019cd2d1-aa47-7fc1-92f9-cc6c49b19460 -->
+* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic \`@\` selectors (\`@latest\`, \`@most\_frequent\`) in \`parseIssueArg\` are detected early (before \`validateResourceId\`) because \`@\` is not in the forbidden charset. \`SELECTOR\_MAP\` provides case-insensitive matching with common variations (\`@mostfrequent\`, \`@most-frequent\`). Resolution in \`resolveSelector\` (issue/utils.ts) maps selectors to \`IssueSort\` values (\`date\`, \`freq\`), calls \`listIssuesPaginated\` with \`perPage: 1\` and \`query: 'is:unresolved'\`. Supports org-prefixed form: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through to suffix-only parsing (not an error). The \`ParsedIssueArg\` union includes \`{ type: 'selector'; selector: IssueSelector; org?: string }\`.
 
-<!-- lore:019ca9c3-989c-7c8d-bcd0-9f308fd2c3d7 -->
-* **Sentry CLI markdown-first formatting pipeline replaces ad-hoc ANSI**: Formatters build CommonMark strings; \`renderMarkdown()\` renders to ANSI for TTY or raw markdown for non-TTY. Key helpers: \`colorTag()\`, \`mdKvTable()\`, \`mdRow()\`, \`mdTableHeader()\` (\`:\` suffix = right-aligned), \`renderTextTable()\`. \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`!isTTY\`. Batch path: \`formatXxxTable()\`. Streaming path: \`StreamingTable\` (TTY) or raw markdown rows (plain). Both share \`buildXxxRowCells()\`.
+### Decision
 
-<!-- lore:019cd2b7-bb98-730e-a0d3-ec25bfa6cf4c -->
-* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: The \`stats\` field on issues is \`{ '24h': \[\[ts, count], ...] }\`. Key depends on \`groupStatsPeriod\` param (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`). \`statsPeriod\` controls time window; \`groupStatsPeriod\` controls stats key. \*\*Critical\*\*: \`count\` is period-scoped — \`lifetime.count\` is the true lifetime total. Issue list table uses \`groupStatsPeriod: 'auto'\` for sparkline data. Column order: SHORT ID, ISSUE, SEEN, AGE, TREND, EVENTS, USERS, TRIAGE. TREND auto-hidden when terminal < 100 cols. \`--compact\` tri-state: explicit overrides; \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`, false for non-TTY. Height is \`3N + 3\` (not \`3N + 4\`) because last data row has no trailing separator.
+<!-- lore:019cc2ef-9be5-722d-bc9f-b07a8197eeed -->
+* **All view subcommands should use \<target> \<id> positional pattern**: All \`\* view\` subcommands should follow a consistent \`\<target> \<id>\` positional argument pattern where target is the optional \`org/project\` specifier. During migration, use opportunistic argument swapping with a stderr warning when args are in wrong order. This is an instance of the broader CLI UX auto-correction pattern: safe when input is already invalid, correction is unambiguous, warning goes to stderr. Normalize at command level, keep parsers pure. Model after \`gh\` CLI conventions.
 
-<!-- lore:019ca9c3-98a2-7a81-9db7-d36c2e71237c -->
-* **Sentry trace-logs API is org-scoped, not project-scoped**: The Sentry trace-logs endpoint (\`/organizations/{org}/trace-logs/\`) is org-scoped, so \`trace logs\` uses \`resolveOrg()\` not \`resolveOrgAndProject()\`. The endpoint is PRIVATE in Sentry source, excluded from the public OpenAPI schema — \`@sentry/api\` has no generated types. The hand-written \`TraceLogSchema\` in \`src/types/sentry.ts\` is required until Sentry makes it public.
-
-<!-- lore:019cbf3f-6dc2-727d-8dca-228555e9603f -->
-* **withAuthGuard returns discriminated Result type, not fallback+onError**: \`withAuthGuard\<T>(fn)\` in \`src/lib/errors.ts\` returns a discriminated Result: \`{ ok: true, value: T } | { ok: false, error: unknown }\`. AuthErrors always re-throw (triggers bin.ts auto-login). All other errors are captured. Callers inspect \`result.ok\` to degrade gracefully. Used across 12+ files.
-
 ### Gotcha
 
-<!-- lore:019c9994-d161-783e-8b3e-79457cd62f42 -->
-* **Biome lint: Response.redirect() required, nested ternaries forbidden**: Biome lint rules that frequently trip up this codebase: (1) \`useResponseRedirect\`: use \`Response.redirect(url, status)\` not \`new Response\`. (2) \`noNestedTernary\`: use \`if/else\`. (3) \`noComputedPropertyAccess\`: use \`obj.property\` not \`obj\["property"]\`. (4) Max cognitive complexity 15 per function — extract helpers to stay under.
+<!-- lore:019cd379-4c6a-7a93-beae-b1d5b4df69b1 -->
+* **Dot-notation field filtering is ambiguous for keys containing dots**: The \`filterFields\` function in \`src/lib/formatters/json.ts\` uses dot-notation to address nested fields (e.g., \`metadata.value\`). This means object keys that literally contain dots are ambiguous and cannot be addressed. Property-based tests for this function must generate field name arbitraries that exclude dots — use a restricted charset like \`\[a-zA-Z0-9\_]\` in fast-check arbitraries. Counterexample found by fast-check: \`{"a":{".":false}}\` with path \`"a."\` splits into \`\["a", ""]\` and fails to resolve.
 
-<!-- lore:019c8c31-f52f-7230-9252-cceb907f3e87 -->
-* **Bugbot flags defensive null-checks as dead code — keep them with JSDoc justification**: Cursor Bugbot and Sentry Seer repeatedly flag two false positives: (1) defensive null-checks as "dead code" — keep them with JSDoc explaining why the guard exists for future safety, especially when removing would require \`!\` assertions banned by \`noNonNullAssertion\`. (2) stderr spinner output during \`--json\` mode — always a false positive since progress goes to stderr, JSON to stdout. Reply explaining the rationale and resolve.
+<!-- lore:019cbe0d-d03e-716c-b372-b09998c07ed6 -->
+* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed.
 
-<!-- lore:019cc3e6-0cdd-7a53-9eb7-a284a3b4eb78 -->
-* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins requires a \`default\` re-export plus all named exports. Missing any causes \`SyntaxError: Export named 'X' not found\`. Always check the real module's full export list. (2) \`Bun.mmap()\` always opens with PROT\_WRITE — macOS SIGKILL on signed Mach-O, Linux ETXTBSY. Fix: use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` in bspatch.ts. (3) Wrap \`Bun.which()\` with optional \`pathEnv\` param for deterministic testing without mocks.
-
 ### Pattern
 
-<!-- lore:dbd63348-2049-42b3-bb99-d6a3d64369c7 -->
-* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\<short-description>\` or \`fix/\<issue-number>-\<short-description>\` (e.g., \`feat/ghcr-nightly-distribution\`, \`fix/268-limit-auto-pagination\`). Commit message format: \`type(scope): description (#issue)\` (e.g., \`fix(issue-list): auto-paginate --limit beyond 100 (#268)\`, \`feat(nightly): distribute via GHCR instead of GitHub Releases\`). Types seen: fix, refactor, meta, release, feat. PRs are created as drafts via \`gh pr create --draft\`. Implementation plans are attached to commits via \`git notes add\` rather than in PR body or commit message.
+<!-- lore:019cce8d-f2d6-7862-9105-7a0048f0e993 -->
+* **Property-based tests for input validators use stringMatching for forbidden char coverage**: In \`test/lib/input-validation.property.test.ts\`, forbidden-character arbitraries are built with \`stringMatching\` targeting specific regex patterns (e.g., \`/^\[^\x00-\x1f]\*\[\x00-\x1f]\[^\x00-\x1f]\*$/\` for control chars). This ensures fast-check generates strings that always contain the forbidden character while varying surrounding content. The \`biome-ignore lint/suspicious/noControlCharactersInRegex\` suppression is needed on the control char regex constant in \`input-validation.ts\`.
 
-<!-- lore:019cc3e6-0cf5-720d-beb7-97c9c9901295 -->
-* **Codecov patch coverage only counts test:unit and test:isolated, not E2E**: CI coverage merges \`test:unit\` (\`test/lib test/commands test/types --coverage\`) and \`test:isolated\` (\`test/isolated --coverage\`) into \`coverage/merged.lcov\`. E2E tests (\`test/e2e\`) are NOT included in coverage reports. So func tests that spy on exports (e.g., \`spyOn(apiClient, 'getLogs')\`) give zero coverage to the mocked function's body. To cover \`api-client.ts\` function bodies in unit tests, mock \`globalThis.fetch\` + \`setOrgRegion()\` + \`setAuthToken()\` and call the real function.
+<!-- lore:019cd379-4c71-7477-9cc6-3c0dfc7fb597 -->
+* **Shared flag constants in list-command.ts for cross-command consistency**: \`src/lib/list-command.ts\` exports shared Stricli flag definitions (\`FIELDS\_FLAG\`, \`FRESH\_FLAG\`, \`FRESH\_ALIASES\`) reused across all commands. When adding a new global-ish flag to multiple commands, define it once here as a const satisfying Stricli's flag shape, then spread into each command's \`flags\` object. The \`--fields\` flag is \`{ kind: 'parsed', parse: String, brief: '...', optional: true }\`. \`parseFieldsList()\` in \`formatters/json.ts\` handles comma-separated parsing with trim/dedup. \`writeJson()\` accepts an optional \`fields\` array and calls \`filterFields()\` before serialization.
 
-<!-- lore:019c90f5-913b-7995-8bac-84289cf5d6d9 -->
-* **Pagination contextKey must include all query-varying parameters with escaping**: Pagination \`contextKey\` must encode every query-varying parameter (sort, query, period) with \`escapeContextKeyValue()\` (replaces \`|\` with \`%7C\`). Always provide a fallback before escaping since \`flags.period\` may be \`undefined\` in tests despite having a default: \`flags.period ? escapeContextKeyValue(flags.period) : "90d"\`.
-
-<!-- lore:019c8a8a-64ee-703c-8c1e-ed32ae8a90a7 -->
-* **PR review workflow: reply, resolve, amend, force-push**: PR review workflow: (1) Read unresolved threads via GraphQL, (2) make code changes, (3) run lint+typecheck+tests, (4) create a SEPARATE commit per review round (not amend) for incremental review, (5) push normally, (6) reply to comments via REST API, (7) resolve threads via GraphQL \`resolveReviewThread\`. Only amend+force-push when user explicitly asks or pre-commit hook modified files.
-
-<!-- lore:019cdd9b-330a-784f-9487-0abf7b80be3c -->
-* **Stricli optional boolean flags produce tri-state (true/false/undefined)**: Stricli boolean flags with \`optional: true\` (no \`default\`) produce \`boolean | undefined\` in the flags type. \`--flag\` → \`true\`, \`--no-flag\` → \`false\`, omitted → \`undefined\`. This enables auto-detect patterns: explicit user choice overrides, \`undefined\` triggers heuristic. Used by \`--compact\` on issue list. The flag type must be \`readonly field?: boolean\` (not \`readonly field: boolean\`). This differs from \`default: false\` which always produces a defined boolean.
-
-<!-- lore:019cc325-d322-7e6e-86cc-93010b71abee -->
-* **Testing Stricli command func() bodies via spyOn mocking**: Stricli/Bun test patterns: (1) Command func tests: \`const func = await cmd.loader()\`, then \`func.call(mockContext, flags, ...args)\`. \`loader()\` return type union causes LSP errors — false positives that pass \`tsc\`. File naming: \`\*.func.test.ts\`. (2) ESM prevents \`vi.spyOn\` on Node built-in exports. Workaround: test subclass that overrides the method calling the built-in. (3) Follow-mode uses \`setTimeout\`-based scheduling; test with \`interceptSigint()\` helper. \`Bun.sleep()\` has no AbortSignal so \`setTimeout\`/\`clearTimeout\` required.
+<!-- lore:019cbe44-7687-7288-81a2-662feefc28ea -->
+* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`.
 <!-- End lore-managed section -->

diff --git a/src/commands/api.ts b/src/commands/api.ts
--- a/src/commands/api.ts
+++ b/src/commands/api.ts
@@ -9,6 +9,7 @@
 import { buildSearchParams, rawApiRequest } from "../lib/api-client.js";
 import { buildCommand } from "../lib/command.js";
 import { OutputError, ValidationError } from "../lib/errors.js";
+import { commandOutput } from "../lib/formatters/output.js";
 import { validateEndpoint } from "../lib/input-validation.js";
 import { logger } from "../lib/logger.js";
 import { getDefaultSdkConfig } from "../lib/sentry-client.js";
@@ -1168,14 +1169,12 @@
 
     // Dry-run mode: preview the request that would be sent
     if (flags["dry-run"]) {
-      yield {
-        data: {
-          method: flags.method,
-          url: resolveRequestUrl(normalizedEndpoint, params),
-          headers: resolveEffectiveHeaders(headers, body),
-          body: body ?? null,
-        },
-      };
+      yield commandOutput({
+        method: flags.method,
+        url: resolveRequestUrl(normalizedEndpoint, params),
+        headers: resolveEffectiveHeaders(headers, body),
+        body: body ?? null,
+      });
       return;
     }
 
@@ -1211,7 +1210,7 @@
       throw new OutputError(response.body);
     }
 
-    yield { data: response.body };
+    yield commandOutput(response.body);
     return;
   },
 });

diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts
--- a/src/commands/auth/logout.ts
+++ b/src/commands/auth/logout.ts
@@ -15,6 +15,7 @@
 import { getDbPath } from "../../lib/db/index.js";
 import { AuthError } from "../../lib/errors.js";
 import { formatLogoutResult } from "../../lib/formatters/human.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 
 /** Structured result of the logout operation */
 export type LogoutResult = {
@@ -38,9 +39,10 @@
   },
   async *func(this: SentryContext) {
     if (!(await isAuthenticated())) {
-      yield {
-        data: { loggedOut: false, message: "Not currently authenticated." },
-      };
+      yield commandOutput({
+        loggedOut: false,
+        message: "Not currently authenticated.",
+      });
       return;
     }
 
@@ -56,12 +58,10 @@
     const configPath = getDbPath();
     await clearAuth();
 
-    yield {
-      data: {
-        loggedOut: true,
-        configPath,
-      },
-    };
+    yield commandOutput({
+      loggedOut: true,
+      configPath,
+    });
     return;
   },
 });

diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts
--- a/src/commands/auth/refresh.ts
+++ b/src/commands/auth/refresh.ts
@@ -15,6 +15,7 @@
 import { AuthError } from "../../lib/errors.js";
 import { success } from "../../lib/formatters/colors.js";
 import { formatDuration } from "../../lib/formatters/human.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 
 type RefreshFlags = {
   readonly json: boolean;
@@ -104,7 +105,7 @@
         : undefined,
     };
 
-    yield { data: payload };
+    yield commandOutput(payload);
     return;
   },
 });

diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts
--- a/src/commands/auth/status.ts
+++ b/src/commands/auth/status.ts
@@ -22,6 +22,7 @@
 import { getUserInfo } from "../../lib/db/user.js";
 import { AuthError, stringifyUnknown } from "../../lib/errors.js";
 import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   FRESH_ALIASES,
@@ -189,7 +190,7 @@
       verification: await verifyCredentials(),
     };
 
-    yield { data };
+    yield commandOutput(data);
     return;
   },
 });

diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts
--- a/src/commands/auth/whoami.ts
+++ b/src/commands/auth/whoami.ts
@@ -13,6 +13,7 @@
 import { setUserInfo } from "../../lib/db/user.js";
 import { AuthError } from "../../lib/errors.js";
 import { formatUserIdentity } from "../../lib/formatters/index.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   FRESH_ALIASES,
@@ -65,7 +66,7 @@
       // Cache update failure is non-essential — user identity was already fetched.
     }
 
-    yield { data: user };
+    yield commandOutput(user);
     return;
   },
 });

diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts
--- a/src/commands/cli/feedback.ts
+++ b/src/commands/cli/feedback.ts
@@ -14,6 +14,7 @@
 import { buildCommand } from "../../lib/command.js";
 import { ConfigError, ValidationError } from "../../lib/errors.js";
 import { formatFeedbackResult } from "../../lib/formatters/human.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 
 /** Structured result of the feedback submission */
 export type FeedbackResult = {
@@ -66,12 +67,10 @@
     // Flush to ensure feedback is sent before process exits
     const sent = await Sentry.flush(3000);
 
-    yield {
-      data: {
-        sent,
-        message,
-      },
-    };
+    yield commandOutput({
+      sent,
+      message,
+    });
     return;
   },
 });

diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts
--- a/src/commands/cli/fix.ts
+++ b/src/commands/cli/fix.ts
@@ -17,6 +17,7 @@
 } from "../../lib/db/schema.js";
 import { OutputError } from "../../lib/errors.js";
 import { formatFixResult } from "../../lib/formatters/human.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import { getRealUsername } from "../../lib/utils.js";
 
 type FixFlags = {
@@ -734,7 +735,7 @@
       throw new OutputError(result);
     }
 
-    yield { data: result };
+    yield commandOutput(result);
     return;
   },
 });

diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts
--- a/src/commands/cli/upgrade.ts
+++ b/src/commands/cli/upgrade.ts
@@ -31,6 +31,7 @@
 } from "../../lib/db/release-channel.js";
 import { UpgradeError } from "../../lib/errors.js";
 import { formatUpgradeResult } from "../../lib/formatters/human.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import { logger } from "../../lib/logger.js";
 import {
   detectInstallationMethod,
@@ -493,7 +494,7 @@
       flags,
     });
     if (resolved.kind === "done") {
-      yield { data: resolved.result };
+      yield commandOutput(resolved.result);
       return;
     }
 
@@ -510,17 +511,15 @@
         target,
         versionArg
       );
-      yield {
-        data: {
-          action: downgrade ? "downgraded" : "upgraded",
-          currentVersion: CLI_VERSION,
-          targetVersion: target,
-          channel,
-          method,
-          forced: flags.force,
-          warnings,
-        } satisfies UpgradeResult,
-      };
+      yield commandOutput({
+        action: downgrade ? "downgraded" : "upgraded",
+        currentVersion: CLI_VERSION,
+        targetVersion: target,
+        channel,
+        method,
+        forced: flags.force,
+        warnings,
+      } satisfies UpgradeResult);
       return;
     }
 
@@ -532,16 +531,14 @@
       execPath: this.process.execPath,
     });
 
-    yield {
-      data: {
-        action: downgrade ? "downgraded" : "upgraded",
-        currentVersion: CLI_VERSION,
-        targetVersion: target,
-        channel,
-        method,
-        forced: flags.force,
-      } satisfies UpgradeResult,
-    };
+    yield commandOutput({
+      action: downgrade ? "downgraded" : "upgraded",
+      currentVersion: CLI_VERSION,
+      targetVersion: target,
+      channel,
+      method,
+      forced: flags.force,
+    } satisfies UpgradeResult);
     return;
   },
 });

diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts
--- a/src/commands/event/view.ts
+++ b/src/commands/event/view.ts
@@ -23,6 +23,7 @@
 import { buildCommand } from "../../lib/command.js";
 import { ContextError, ResolutionError } from "../../lib/errors.js";
 import { formatEventDetails } from "../../lib/formatters/index.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   FRESH_ALIASES,
@@ -380,12 +381,11 @@
       ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans }
       : null;
 
-    yield {
-      data: { event, trace, spanTreeLines: spanTreeResult?.lines },
+    yield commandOutput({ event, trace, spanTreeLines: spanTreeResult?.lines });
+    return {
       hint: target.detectedFrom
         ? `Detected from ${target.detectedFrom}`
         : undefined,
     };
-    return;
   },
 });

diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts
--- a/src/commands/issue/explain.ts
+++ b/src/commands/issue/explain.ts
@@ -7,6 +7,7 @@
 import type { SentryContext } from "../../context.js";
 import { buildCommand } from "../../lib/command.js";
 import { ApiError } from "../../lib/errors.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   formatRootCauseList,
   handleSeerApiError,
@@ -104,11 +105,8 @@
         );
       }
 
-      yield {
-        data: causes,
-        hint: `To create a plan, run: sentry issue plan ${issueArg}`,
-      };
-      return;
+      yield commandOutput(causes);
+      return { hint: `To create a plan, run: sentry issue plan ${issueArg}` };
     } catch (error) {
       // Handle API errors with friendly messages
       if (error instanceof ApiError) {

diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts
--- a/src/commands/issue/list.ts
+++ b/src/commands/issue/list.ts
@@ -42,8 +42,11 @@
   shouldAutoCompact,
   writeIssueTable,
 } from "../../lib/formatters/index.js";
-import type { OutputConfig } from "../../lib/formatters/output.js";
 import {
+  commandOutput,
+  type OutputConfig,
+} from "../../lib/formatters/output.js";
+import {
   applyFreshFlag,
   buildListCommand,
   buildListLimitFlag,
@@ -1378,7 +1381,7 @@
       combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint;
     }
 
-    yield { data: result, hint: combinedHint };
-    return;
+    yield commandOutput(result);
+    return { hint: combinedHint };
   },
 });

diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts
--- a/src/commands/issue/plan.ts
+++ b/src/commands/issue/plan.ts
@@ -9,6 +9,7 @@
 import { triggerSolutionPlanning } from "../../lib/api-client.js";
 import { buildCommand, numberParser } from "../../lib/command.js";
 import { ApiError, ValidationError } from "../../lib/errors.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   formatSolution,
   handleSeerApiError,
@@ -225,7 +226,7 @@
       if (!flags.force) {
         const existingSolution = extractSolution(state);
         if (existingSolution) {
-          yield { data: buildPlanData(state) };
+          yield commandOutput(buildPlanData(state));
           return;
         }
       }
@@ -261,7 +262,7 @@
         throw new Error("Plan creation was cancelled.");
       }
 
-      yield { data: buildPlanData(finalState) };
+      yield commandOutput(buildPlanData(finalState));
       return;
     } catch (error) {
       // Handle API errors with friendly messages

diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts
--- a/src/commands/issue/view.ts
+++ b/src/commands/issue/view.ts
@@ -14,6 +14,7 @@
   formatIssueDetails,
   muted,
 } from "../../lib/formatters/index.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   FRESH_ALIASES,
@@ -170,10 +171,9 @@
       ? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans }
       : null;
 
-    yield {
-      data: { issue, event: event ?? null, trace, spanTreeLines },
+    yield commandOutput({ issue, event: event ?? null, trace, spanTreeLines });
+    return {
       hint: `Tip: Use 'sentry issue explain ${issueArg}' for AI root cause analysis`,
     };
-    return;
   },
 });

diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts
--- a/src/commands/log/list.ts
+++ b/src/commands/log/list.ts
@@ -22,6 +22,10 @@
 } from "../../lib/formatters/index.js";
 import { filterFields } from "../../lib/formatters/json.js";
 import { renderInlineMarkdown } from "../../lib/formatters/markdown.js";
+import {
+  type CommandOutput,
+  commandOutput,
+} from "../../lib/formatters/output.js";
 import type { StreamingTable } from "../../lib/formatters/text-table.js";
 import {
   applyFreshFlag,
@@ -177,20 +181,20 @@
 function* yieldStreamChunks(
   chunk: LogStreamChunk,
   json: boolean
-): Generator<{ data: LogListOutput }, void, undefined> {
+): Generator<CommandOutput<LogListOutput>, void, undefined> {
   if (json) {
     // In JSON mode, expand data chunks into one yield per log for JSONL
     if (chunk.kind === "data") {
       for (const log of chunk.logs) {
         // Yield a single-log data chunk so jsonTransform emits one line
-        yield { data: { kind: "data", logs: [log] } };
+        yield commandOutput({ kind: "data", logs: [log] } as LogListOutput);
       }
     }
     // Text chunks suppressed in JSON mode (jsonTransform returns undefined)
     return;
   }
   // Human mode: yield the chunk directly for the human formatter
-  yield { data: chunk };
+  yield commandOutput(chunk);
 }
 
 /**
@@ -685,8 +689,8 @@
       // Only forward hint to the footer when items exist — empty results
       // already render hint text inside the human formatter.
       const hint = result.logs.length > 0 ? result.hint : undefined;
-      yield { data: result, hint };
-      return;
+      yield commandOutput(result);
+      return { hint };
     }
 
     // Standard project-scoped mode — kept in else-like block to avoid
@@ -732,7 +736,8 @@
       // Only forward hint to the footer when items exist — empty results
       // already render hint text inside the human formatter.
       const hint = result.logs.length > 0 ? result.hint : undefined;
-      yield { data: result, hint };
+      yield commandOutput(result);
+      return { hint };
     }
   },
 });

diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts
--- a/src/commands/log/view.ts
+++ b/src/commands/log/view.ts
@@ -18,6 +18,7 @@
 import { ContextError, ValidationError } from "../../lib/errors.js";
 import { formatLogDetails } from "../../lib/formatters/index.js";
 import { filterFields } from "../../lib/formatters/json.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import { validateHexId } from "../../lib/hex-id.js";
 import {
   applyFreshFlag,
@@ -389,7 +390,7 @@
       ? `Detected from ${target.detectedFrom}`
       : undefined;
 
-    yield { data: { logs, orgSlug: target.org }, hint };
-    return;
+    yield commandOutput({ logs, orgSlug: target.org });
+    return { hint };
   },
 });

diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts
--- a/src/commands/org/list.ts
+++ b/src/commands/org/list.ts
@@ -10,6 +10,7 @@
 import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js";
 import { getAllOrgRegions } from "../../lib/db/regions.js";
 import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import { type Column, writeTable } from "../../lib/formatters/table.js";
 import {
   applyFreshFlag,
@@ -151,7 +152,7 @@
       hints.push("Tip: Use 'sentry org view <slug>' for details");
     }
 
-    yield { data: entries, hint: hints.join("\n") || undefined };
-    return;
+    yield commandOutput(entries);
+    return { hint: hints.join("\n") || undefined };
   },
 });

diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts
--- a/src/commands/org/view.ts
+++ b/src/commands/org/view.ts
@@ -10,6 +10,7 @@
 import { buildCommand } from "../../lib/command.js";
 import { ContextError } from "../../lib/errors.js";
 import { formatOrgDetails } from "../../lib/formatters/index.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   FRESH_ALIASES,
@@ -78,7 +79,7 @@
     const hint = resolved.detectedFrom
       ? `Detected from ${resolved.detectedFrom}`
       : undefined;
-    yield { data: org, hint };
-    return;
+    yield commandOutput(org);
+    return { hint };
   },
 });

diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts
--- a/src/commands/project/create.ts
+++ b/src/commands/project/create.ts
@@ -35,6 +35,7 @@
   type ProjectCreatedResult,
 } from "../../lib/formatters/human.js";
 import { isPlainOutput } from "../../lib/formatters/markdown.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js";
 import { renderTextTable } from "../../lib/formatters/text-table.js";
 import { logger } from "../../lib/logger.js";
@@ -405,7 +406,7 @@
         expectedSlug,
         dryRun: true,
       };
-      yield { data: result };
+      yield commandOutput(result);
       return;
     }
 
@@ -433,7 +434,7 @@
       expectedSlug,
     };
 
-    yield { data: result };
+    yield commandOutput(result);
     return;
   },
 });

diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts
--- a/src/commands/project/list.ts
+++ b/src/commands/project/list.ts
@@ -32,7 +32,10 @@
 } from "../../lib/db/pagination.js";
 import { ContextError, withAuthGuard } from "../../lib/errors.js";
 import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
-import type { OutputConfig } from "../../lib/formatters/output.js";
+import {
+  commandOutput,
+  type OutputConfig,
+} from "../../lib/formatters/output.js";
 import { type Column, formatTable } from "../../lib/formatters/table.js";
 import {
   applyFreshFlag,
@@ -633,6 +636,7 @@
     // Only forward hint to the footer when items exist — empty results
     // already render hint text inside the human formatter.
     const hint = result.items.length > 0 ? result.hint : undefined;
-    yield { data: result, hint };
+    yield commandOutput(result);
+    return { hint };
   },
 });

diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts
--- a/src/commands/project/view.ts
+++ b/src/commands/project/view.ts
@@ -15,6 +15,7 @@
 import { buildCommand } from "../../lib/command.js";
 import { ContextError, withAuthGuard } from "../../lib/errors.js";
 import { divider, formatProjectDetails } from "../../lib/formatters/index.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   FRESH_ALIASES,
@@ -294,7 +295,7 @@
       detectedFrom: targets[i]?.detectedFrom,
     }));
 
-    yield { data: entries, hint: footer };
-    return;
+    yield commandOutput(entries);
+    return { hint: footer };
   },
 });

diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts
--- a/src/commands/trace/list.ts
+++ b/src/commands/trace/list.ts
@@ -15,6 +15,7 @@
 } from "../../lib/db/pagination.js";
 import { formatTraceTable } from "../../lib/formatters/index.js";
 import { filterFields } from "../../lib/formatters/json.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   buildListCommand,
@@ -271,9 +272,7 @@
         : `${countText} Use 'sentry trace view <TRACE_ID>' to view the full span tree.`;
     }
 
-    yield {
-      data: { traces, hasMore, nextCursor, org, project },
-      hint,
-    };
+    yield commandOutput({ traces, hasMore, nextCursor, org, project });
+    return { hint };
   },
 });

diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts
--- a/src/commands/trace/view.ts
+++ b/src/commands/trace/view.ts
@@ -21,6 +21,7 @@
   formatSimpleSpanTree,
   formatTraceSummary,
 } from "../../lib/formatters/index.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import {
   applyFreshFlag,
   FRESH_ALIASES,
@@ -314,10 +315,9 @@
         ? formatSimpleSpanTree(traceId, spans, flags.spans)
         : undefined;
 
-    yield {
-      data: { summary, spans, spanTreeLines },
+    yield commandOutput({ summary, spans, spanTreeLines });
+    return {
       hint: `Tip: Open in browser with 'sentry trace view --web ${traceId}'`,
     };
-    return;
   },
 });

diff --git a/src/commands/trial/list.ts b/src/commands/trial/list.ts
--- a/src/commands/trial/list.ts
+++ b/src/commands/trial/list.ts
@@ -11,6 +11,7 @@
 import { buildCommand } from "../../lib/command.js";
 import { ContextError } from "../../lib/errors.js";
 import { colorTag } from "../../lib/formatters/markdown.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import { type Column, writeTable } from "../../lib/formatters/table.js";
 import { resolveOrg } from "../../lib/resolve-target.js";
 import {
@@ -265,7 +266,7 @@
       );
     }
 
-    yield { data: entries, hint: hints.join("\n") || undefined };
-    return;
+    yield commandOutput(entries);
+    return { hint: hints.join("\n") || undefined };
   },
 });

diff --git a/src/commands/trial/start.ts b/src/commands/trial/start.ts
--- a/src/commands/trial/start.ts
+++ b/src/commands/trial/start.ts
@@ -22,6 +22,7 @@
 import { buildCommand } from "../../lib/command.js";
 import { ContextError, ValidationError } from "../../lib/errors.js";
 import { success } from "../../lib/formatters/colors.js";
+import { commandOutput } from "../../lib/formatters/output.js";
 import { logger } from "../../lib/logger.js";
 import { generateQRCode } from "../../lib/qrcode.js";
 import { resolveOrg } from "../../lib/resolve-target.js";
@@ -142,7 +143,8 @@
 
     // Plan trial: no API to start it — open billing page instead
     if (parsed.name === "plan") {
-      yield await handlePlanTrial(orgSlug, this.stdout, flags.json ?? false);
+      const result = await handlePlanTrial(orgSlug, this.stdout, flags.json ?? false);
+      yield commandOutput(result.data);
       return;
     }
 
@@ -161,16 +163,13 @@
     // Start the trial
     await startProductTrial(orgSlug, trial.category);
 
-    yield {
-      data: {
-        name: parsed.name,
-        category: trial.category,
-        organization: orgSlug,
-        lengthDays: trial.lengthDays,
-        started: true,
-      },
-      hint: undefined,
-    };
+    yield commandOutput({
... diff truncated: showing 800 of 1335 lines

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@BYK BYK force-pushed the feat/generator-commands branch from db3aaaf to 353b03d Compare March 13, 2026 14:31
BYK added 2 commits March 13, 2026 14:51
All command functions now use async generator signatures. The framework
iterates each yielded value through the existing OutputConfig rendering
pipeline. Non-streaming commands yield once and return; streaming
commands (log list --follow) yield multiple times.

Key changes:
- buildCommand: func returns AsyncGenerator, wrapper uses for-await-of
- All ~27 command files: async func → async *func, return → yield
- log/list follow mode: drainStreamingOutput replaced by yield delegation
- Delete streaming-command.ts (151 lines) — absorbed into buildCommand
- output.ts: extracted applyJsonExclude/writeTransformedJson helpers,
  support undefined suppression for streaming text-only chunks

No new dependencies. Net -131 lines.
… return

Three improvements to the command framework's output system:

1. **Brand CommandOutput with Symbol discriminant**
   - Add COMMAND_OUTPUT_BRAND Symbol to CommandOutput type
   - Add commandOutput() factory function for creating branded values
   - Replace duck-typing ('data' in v) with Symbol check in isCommandOutput()
   - Prevents false positives from raw API responses with 'data' property

2. **Move hints from yield to generator return value**
   - Add CommandReturn type ({ hint?: string }) for generator return values
   - Switch from for-await-of to manual .next() iteration to capture return
   - renderCommandOutput no longer handles hints; wrapper renders post-loop
   - All commands: yield commandOutput(data) + return { hint }

3. **Eliminate noExplicitAny suppressions in command.ts**
   - wrappedFunc params: any → Record<string, unknown> / unknown[]
   - Final Stricli cast: as any → as unknown as StricliBuilderArgs<CONTEXT>
   - OutputConfig<any> kept with improved variance explanation
   - renderCommandOutput config param: kept any with contravariance docs

All 24 command files migrated to use commandOutput() helper.
Tests updated for branded outputs and hint-on-return pattern.

1083 tests pass, 0 fail, 9356 assertions across 38 files.
@BYK BYK force-pushed the feat/generator-commands branch from 353b03d to 13047a5 Compare March 13, 2026 14:52
@BYK BYK changed the title refactor: unify all command functions as async generators refactor: brand CommandOutput, unify commands as generators, move hints to return Mar 13, 2026
Commands no longer receive or pass stdout/stderr Writer references.
Interactive and diagnostic output now routes through the project's
consola logger (→ stderr), keeping stdout reserved for structured
command output.

Changes:
- org-list.ts: remove stdout from HandlerContext and DispatchOptions
- list-command.ts, project/list.ts, issue/list.ts, trace/logs.ts:
  stop threading stdout to dispatchOrgScopedList
- issue/list.ts: convert partial-failure stderr.write to logger.warn
- log/list.ts: convert follow-mode banner and onDiagnostic to logger
- auth/login.ts, interactive-login.ts: remove stdout/stderr params,
  use logger for all UI output (QR code, URLs, progress dots)
- clipboard.ts: remove stdout param from setupCopyKeyListener
- trial/start.ts: use logger for billing URL and QR code display
- help.ts: printCustomHelp returns string, caller writes to process.stdout
- formatters/log.ts: rename displayTraceLogs → formatTraceLogs (returns string)
- formatters/output.ts: extract formatFooter helper from writeFooter

Remaining stdout usage:
- auth/token.ts: intentional raw stdout for pipe compatibility
- help.ts: process.stdout.write for help text (like git --help)
- trace/logs.ts: process.stdout.write (pending OutputConfig migration)
@BYK BYK changed the title refactor: brand CommandOutput, unify commands as generators, move hints to return refactor: brand CommandOutput, unify commands as generators, remove stdout plumbing Mar 13, 2026
BYK and others added 3 commits March 13, 2026 18:29
…utput

Convert auth/login from a void generator using logger.info() to yield
commandOutput(result) with a proper OutputConfig (human + JSON).

Changes:
- New LoginResult type in interactive-login.ts (method, user, configPath,
  expiresIn)
- runInteractiveLogin returns LoginResult | null instead of boolean
- loginCommand gets output: { json: true, human: formatLoginResult }
- Token path builds LoginResult and yields it
- OAuth path receives LoginResult from runInteractiveLogin and yields it
- Interactive UI (QR code, polling dots, prompts) stays on stderr via logger
- Structured result (identity, config path, expiry) goes to stdout via yield
- Tests updated: getStdout() for command output assertions, behavioral spy
  checks for early-exit paths (logger message assertions removed — unreliable
  with mock.module contamination from login-reauth.test.ts)
…dering

The log list command no longer knows about output format. Instead of
yielding separate 'text' and 'data' chunks (LogStreamChunk), the
generator yields raw LogLike[] batches and the func wraps them in
LogListResult with streaming: true. The OutputConfig formatters handle
all rendering decisions.

Removed:
- LogStreamChunk discriminated union (text | data)
- yieldStreamChunks() — format-aware fan-out function
- LogListOutput union type
- yieldBatch() sub-generator with text/data split
- includeTrace from FollowGeneratorConfig (rendering concern)

Added:
- LogListResult.streaming flag — formatters use it to switch between
  full-table (batch) and incremental-row (streaming) rendering
- jsonlLines() in output.ts — JSONL support for the framework. When
  jsonTransform returns jsonlLines(items), writeTransformedJson writes
  each item as a separate line instead of serializing as one JSON value
- yieldFollowBatches() helper — consumes follow generator and yields
  CommandOutput<LogListResult> with streaming: true
- Stateful streaming table state (module-level singleton, reset per run)

The func body has zero references to flags.json for output decisions.
The only flags.json usage is in writeFollowBanner (stderr noise control).
BYK added 3 commits March 13, 2026 23:08
OutputConfig.human is now a factory `() => HumanRenderer<T>` called once
per command invocation, instead of a plain `(data: T) => string`.

HumanRenderer has two methods:
- `render(data: T) => string` — called per yielded value
- `finalize?(hint?: string) => string` — called once after the generator
  completes, replaces the default writeFooter(hint) behavior

This enables streaming commands to maintain per-invocation rendering
state (e.g., a table that tracks header/footer) without module-level
singletons. The wrapper resolves the factory once before iterating,
passes the renderer to renderCommandOutput, and calls finalize() after
the generator completes.

For stateless commands (all current ones), the `stateless(fn)` helper
wraps a plain formatter: `human: stateless(formatMyData)`.

Framework changes:
- output.ts: HumanRenderer<T> type, stateless() helper, updated
  renderCommandOutput to accept renderer parameter, CommandReturn.hint
  docs updated
- command.ts: resolves renderer before iteration, passes to
  handleYieldedValue, writeFinalization() calls finalize or writeFooter

28 command files: mechanical human: fn → human: stateless(fn)
Replace the module-level streaming table singleton with a per-invocation
HumanRenderer factory (createLogRenderer). The factory creates fresh
StreamingTable state and returns render() + finalize() callbacks.

render() handles all yields uniformly — both single-fetch and follow
mode. It emits the table header on the first non-empty batch and rows
per batch.

finalize(hint?) emits the table footer (when headers were emitted) and
appends the hint text. This replaces the empty-batch sentinel pattern
where `{ logs: [], streaming: true }` was yielded to trigger the
table footer.

Other changes:
- Remove `streaming` flag from LogListResult (was rendering concern)
- Add `jsonl` flag for JSON serialization mode (JSONL vs array)
- Remove `hint` from LogListResult (moves to CommandReturn)
- executeSingleFetch/executeTraceSingleFetch return FetchResult
  with separate `result` and `hint` fields
- Remove resetStreamingState(), yieldFollowBatches sentinel
- Fix duplicate JSDoc on writeFollowBanner
- Factory creates fresh renderer per config.human() call
- finalize(hint) returns combined footer + hint string
- stateless() wrapper has no finalize method
Missing stateless import and wrapping for OutputConfig.human in
buildCommand test suite. Pre-existing help.test.ts failures (5)
are not related to this change.
1. formatTraceLogs: add trailing newline to JSON output (log.ts)
2. output.ts: fix orphaned JSDoc — move writeFooter docs to writeFooter,
   leave formatFooter with its own one-liner
3. jsonTransformLogOutput: return [] for empty single-fetch, undefined
   only for empty follow-mode batches (log/list.ts)
4. finalize: remove extra trailing newline after formatFooter (log/list.ts)
5. command.ts: move writeFinalization to finally block so streaming table
   footer is written even on mid-stream errors
6. trial/start.ts: check isatty(2) instead of isatty(1) since prompts
   display on stderr after migration
printCustomHelp now returns a string instead of accepting a Writer.
Update tests to call without arguments and assert on return value.
filterFields expects a single object, not an array. Map over each
log entry individually to apply field filtering correctly.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

1. command.ts: Move writeFinalization out of finally block. On error,
   finalize only in human mode (to close streaming table) before
   handleOutputError. Prevents corrupting JSON output or writing
   footer after process.exit().

2. log/list.ts: When no logs were rendered (headerEmitted=false),
   render hint as primary text instead of muted footer. Preserves
   the 'No logs found.' UX from before the refactor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant