refactor: brand CommandOutput, unify commands as generators, remove stdout plumbing#416
refactor: brand CommandOutput, unify commands as generators, remove stdout plumbing#416
Conversation
Semver Impact of This PR🟢 Patch (bug fixes) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨Init
Issue List
Other
Bug Fixes 🐛Dsn
Init
Other
Documentation 📚
Internal Changes 🔧Init
Tests
Other
Other
🤖 This preview updates automatically when you update the PR. |
578fc7a to
f111da6
Compare
Codecov Results 📊✅ 111 passed | Total: 111 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
✨ No test changes detected All tests are passing successfully. ✅ Patch coverage is 93.84%. Project has 1059 uncovered lines. Files with missing lines (8)
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 |
bab6697 to
db3aaaf
Compare
There was a problem hiding this comment.
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.
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 linesThis Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
db3aaaf to
353b03d
Compare
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.
353b03d to
13047a5
Compare
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)
…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).
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
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.


Summary
Three changes to the command framework output system:
1. Unify all command functions as async generators
Every command
funcis now anasync *funcgenerator. This is the prerequisite for streaming output — commands yield values instead of writing to stdout directly.async funcare converted toasync *funcauth/login) get auseYieldlint suppression until they are migrated to yield pattern in Phase 6b2. Brand
CommandOutputwith Symbol discriminantReplace duck-typing (
"data" in value) with aCOMMAND_OUTPUT_BRANDSymbol on theCommandOutputtype. This prevents false positives from raw API responses that happen to have adataproperty.commandOutput<T>(data: T)factory function creates branded valuesisCommandOutput()checks the Symbol instead of structural shapecommandOutput()helper3. 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, generatorsreturn { hint }after their final yield.CommandReturntype:{ hint?: string }.next()iteration instead offor-await-ofto capture the return valuerenderCommandOutputno longer handles hints — the wrapper callswriteFooter()after the generator completes4. Remove stdout/stderr plumbing from commands
Commands no longer receive or pass
stdout/stderrWriter references. Interactive and diagnostic output now routes through the project's consola logger (→ stderr), keeping stdout reserved for structured command output.HandlerContextandDispatchOptionsno longer carrystdoutrunInteractiveLoginuses logger instead of Writer paramsdisplayTraceLogs→formatTraceLogs(returns string instead of writing)formatFooter()helper extracted fromwriteFooter()Remaining stdout usage (intentional):
auth/token: raw stdout for pipe compatibility (sentry auth token | pbcopy)help.ts: help text to stdout (likegit --help)trace/logs.ts:process.stdout.write(pending OutputConfig migration)Pattern
Test plan