Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5fc06b9
feat: add --dry-run flag to `sentry api` and `sentry project create`
BYK Mar 10, 2026
736860b
chore: regenerate SKILL.md
github-actions[bot] Mar 10, 2026
a6e26f5
fix: address BugBot findings in dry-run implementation
BYK Mar 10, 2026
8f9b2e0
fix: align multiline JSON body indentation in dry-run output
BYK Mar 10, 2026
ba7e070
fix: dry-run shows would-be auto-created team instead of erroring
BYK Mar 10, 2026
6c4a191
fix: remove output: json from api command, add explicit --json flag
BYK Mar 10, 2026
6bd2e8e
chore: regenerate SKILL.md
github-actions[bot] Mar 10, 2026
d02f40b
fix: restore output: json and support --fields on api response
BYK Mar 10, 2026
597404a
chore: regenerate SKILL.md
github-actions[bot] Mar 10, 2026
2d891cb
refactor: use writeOutput for dry-run in project create
BYK Mar 10, 2026
7f8df47
fix: dry-run includes auto-inferred Content-Type header
BYK Mar 10, 2026
1e91caa
fix: move DryRunData type and formatDryRun after all imports
BYK Mar 10, 2026
849aad2
refactor: use mdKvTable for dry-run output in project create
BYK Mar 10, 2026
d4a7de3
refactor: unify dry-run and normal output in project create
BYK Mar 11, 2026
1617ab3
refactor: api dry-run returns { data } through output wrapper
BYK Mar 11, 2026
70e7f2d
refactor: api command uses JSON-only output for both normal and dry-run
BYK Mar 11, 2026
6980508
refactor: api command uses return-based output, remove buildDryRunReq…
BYK Mar 11, 2026
042d264
remove dead writeResponseBody function and its tests
BYK Mar 11, 2026
997570c
fix: use process.exit(1) for api error responses
BYK Mar 11, 2026
922a650
fix: api command preserves raw string responses, fix null body header…
BYK Mar 11, 2026
b307377
fix: remove duplicate comment line in dry-run section
BYK Mar 11, 2026
864f4d4
refactor: replace exitCode on CommandOutput with OutputError throw pa…
BYK Mar 11, 2026
68758b6
refactor: eliminate all direct stdout writes from api command
BYK Mar 11, 2026
6448bd9
refactor: remove --include, move --verbose to logger.debug()
BYK Mar 11, 2026
c73b1dd
chore: regenerate SKILL.md
github-actions[bot] Mar 11, 2026
f388616
fix: E2E failures from --include removal, --verbose to stderr, null o…
BYK Mar 11, 2026
0e7fc30
fix: consola routes debug/info to stdout in non-TTY mode
BYK Mar 11, 2026
14905d3
fix: --silent suppresses --verbose output, extract log helpers
BYK Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docs/src/content/docs/commands/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,20 @@ sentry api /organizations/ \
--header "X-Custom-Header:value"
```

### Show Response Headers
### Verbose Mode

```bash
sentry api /organizations/ --include
sentry api /organizations/ --verbose
```

```
HTTP/2 200
content-type: application/json
x-sentry-rate-limit-remaining: 95
Request and response metadata is logged to stderr:

```
> GET /api/0/organizations/
>
< HTTP 200
< content-type: application/json
<
[{"slug": "my-org", ...}]
```

Expand Down
7 changes: 5 additions & 2 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ Create a new project

**Flags:**
- `-t, --team <value> - Team to create the project under`
- `-n, --dry-run - Validate inputs and show what would be created without creating it`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

Expand Down Expand Up @@ -395,9 +396,11 @@ Make an authenticated API request
- `-f, --raw-field <value>... - Add a string parameter without JSON parsing`
- `-H, --header <value>... - Add a HTTP request header in key:value format`
- `--input <value> - The file to use as body for the HTTP request (use "-" to read from standard input)`
- `-i, --include - Include HTTP response status line and headers in the output`
- `--silent - Do not print the response body`
- `--verbose - Include full HTTP request and response in the output`
- `-n, --dry-run - Show the resolved request without sending it`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

**Examples:**

Expand Down Expand Up @@ -436,7 +439,7 @@ sentry api /projects/my-org/my-project/ \
sentry api /organizations/ \
--header "X-Custom-Header:value"

sentry api /organizations/ --include
sentry api /organizations/ --verbose

# Get all issues (automatically follows pagination)
sentry api /projects/my-org/my-project/issues/ --paginate
Expand Down
230 changes: 124 additions & 106 deletions src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
*/

import type { SentryContext } from "../context.js";
import { rawApiRequest } from "../lib/api-client.js";
import { buildSearchParams, rawApiRequest } from "../lib/api-client.js";
import { buildCommand } from "../lib/command.js";
import { ValidationError } from "../lib/errors.js";
import { OutputError, ValidationError } from "../lib/errors.js";
import { validateEndpoint } from "../lib/input-validation.js";
import { logger } from "../lib/logger.js";
import { getDefaultSdkConfig } from "../lib/sentry-client.js";
import type { Writer } from "../types/index.js";

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
Expand All @@ -21,9 +23,13 @@ type ApiFlags = {
readonly "raw-field"?: string[];
readonly header?: string[];
readonly input?: string;
readonly include: boolean;
readonly silent: boolean;
readonly verbose: boolean;
readonly "dry-run": boolean;
/** Injected by buildCommand via output config */
readonly json: boolean;
/** Injected by buildCommand via output config */
readonly fields?: string[];
};

// Request Parsing
Expand Down Expand Up @@ -842,105 +848,65 @@ export function buildBodyFromFields(
// Response Output

/**
* Write response headers to stdout (standard format)
* Format a raw response body value for human-readable output.
* Objects are pretty-printed as JSON, strings pass through, null/undefined → empty.
* @internal Exported for testing
*/
export function writeResponseHeaders(
stdout: Writer,
status: number,
headers: Headers
): void {
stdout.write(`HTTP ${status}\n`);
headers.forEach((value, key) => {
stdout.write(`${key}: ${value}\n`);
});
stdout.write("\n");
}

/**
* Write response body to stdout
* @internal Exported for testing
*/
export function writeResponseBody(stdout: Writer, body: unknown): void {
if (body === null || body === undefined) {
return;
export function formatApiResponse(data: unknown): string {
if (data === null || data === undefined) {
return "";
}

if (typeof body === "object") {
stdout.write(`${JSON.stringify(body, null, 2)}\n`);
} else {
stdout.write(`${String(body)}\n`);
if (typeof data === "object") {
return JSON.stringify(data, null, 2);
}
return String(data);
}

/**
* Write verbose request output (curl-style format)
* Resolve the full URL that rawApiRequest would use for a request.
*
* Mirrors the URL construction in rawApiRequest:
* `${baseUrl}/api/0/${endpoint}?${queryString}`
* @internal Exported for testing
*/
export function writeVerboseRequest(
stdout: Writer,
method: string,
export function resolveRequestUrl(
endpoint: string,
headers: Record<string, string> | undefined
): void {
stdout.write(`> ${method} /api/0/${endpoint}\n`);
if (headers) {
for (const [key, value] of Object.entries(headers)) {
stdout.write(`> ${key}: ${value}\n`);
}
}
stdout.write(">\n");
params?: Record<string, string | string[]>
): string {
// Use getDefaultSdkConfig().baseUrl — same as rawApiRequest — to ensure
// trailing slashes are stripped and the URL matches what would be sent.
const { baseUrl } = getDefaultSdkConfig();
const normalizedEndpoint = endpoint.startsWith("/")
? endpoint.slice(1)
: endpoint;
const searchParams = buildSearchParams(params);
const queryString = searchParams ? `?${searchParams.toString()}` : "";
return `${baseUrl}/api/0/${normalizedEndpoint}${queryString}`;
}

/**
* Write verbose response output (curl-style format)
* @internal Exported for testing
*/
export function writeVerboseResponse(
stdout: Writer,
status: number,
headers: Headers
): void {
stdout.write(`< HTTP ${status}\n`);
headers.forEach((value, key) => {
stdout.write(`< ${key}: ${value}\n`);
});
stdout.write("<\n");
}

/**
* Handle response output based on flags
* Resolve effective request headers, mirroring rawApiRequest logic.
*
* Auto-adds Content-Type: application/json for non-string object bodies
* when no Content-Type was explicitly provided.
*
* @internal Exported for testing
*/
export function handleResponse(
stdout: Writer,
response: { status: number; headers: Headers; body: unknown },
flags: { silent: boolean; verbose: boolean; include: boolean }
): void {
const isError = response.status >= 400;

// Silent mode - only set exit code
if (flags.silent) {
if (isError) {
process.exit(1);
}
return;
}

// Output headers (verbose or include mode)
if (flags.verbose) {
writeVerboseResponse(stdout, response.status, response.headers);
} else if (flags.include) {
writeResponseHeaders(stdout, response.status, response.headers);
}

// Output body
writeResponseBody(stdout, response.body);

// Exit with error code for error responses
if (isError) {
process.exit(1);
export function resolveEffectiveHeaders(
customHeaders: Record<string, string> | undefined,
body: unknown
): Record<string, string> {
// Mirror rawApiRequest exactly: auto-add Content-Type for any non-string,
// non-undefined body when no Content-Type was explicitly provided.
const isStringBody = typeof body === "string";
const headers = { ...(customHeaders ?? {}) };
const hasContentType = Object.keys(headers).some(
(k) => k.toLowerCase() === "content-type"
);
if (!(isStringBody || hasContentType) && body !== undefined) {
headers["Content-Type"] = "application/json";
}
return headers;
}

/**
Expand Down Expand Up @@ -1065,7 +1031,34 @@ export async function resolveBody(

// Command Definition

const log = logger.withTag("api");

/** Log outgoing request details in `> ` curl-verbose style. */
function logRequest(
method: string,
endpoint: string,
headers: Record<string, string> | undefined
): void {
log.debug(`> ${method} /api/0/${endpoint}`);
if (headers) {
for (const [key, value] of Object.entries(headers)) {
log.debug(`> ${key}: ${value}`);
}
}
log.debug(">");
}

/** Log incoming response details in `< ` curl-verbose style. */
function logResponse(response: { status: number; headers: Headers }): void {
log.debug(`< HTTP ${response.status}`);
response.headers.forEach((value, key) => {
log.debug(`< ${key}: ${value}`);
});
log.debug("<");
}

export const apiCommand = buildCommand({
output: { json: true, human: formatApiResponse },
docs: {
brief: "Make an authenticated API request",
fullDescription:
Expand Down Expand Up @@ -1143,11 +1136,6 @@ export const apiCommand = buildCommand({
optional: true,
placeholder: "file",
},
include: {
kind: "boolean",
brief: "Include HTTP response status line and headers in the output",
default: false,
},
silent: {
kind: "boolean",
brief: "Do not print the response body",
Expand All @@ -1158,37 +1146,48 @@ export const apiCommand = buildCommand({
brief: "Include full HTTP request and response in the output",
default: false,
},
"dry-run": {
kind: "boolean",
brief: "Show the resolved request without sending it",
default: false,
},
},
aliases: {
X: "method",
d: "data",
F: "field",
f: "raw-field",
H: "header",
i: "include",
n: "dry-run",
},
},
async func(
this: SentryContext,
flags: ApiFlags,
endpoint: string
): Promise<void> {
const { stdout, stderr, stdin } = this;

// Normalize endpoint to ensure trailing slash (Sentry API requirement)
const normalizedEndpoint = normalizeEndpoint(endpoint);
async func(this: SentryContext, flags: ApiFlags, endpoint: string) {
const { stderr, stdin } = this;

// Resolve body and query params from flags (--data, --input, or fields)
const normalizedEndpoint = normalizeEndpoint(endpoint);
const { body, params } = await resolveBody(flags, stdin, stderr);

const headers =
flags.header && flags.header.length > 0
? parseHeaders(flags.header)
: undefined;

// Verbose mode: show request details (unless silent)
if (flags.verbose && !flags.silent) {
writeVerboseRequest(stdout, flags.method, normalizedEndpoint, headers);
// Dry-run mode: preview the request that would be sent
if (flags["dry-run"]) {
return {
data: {
method: flags.method,
url: resolveRequestUrl(normalizedEndpoint, params),
headers: resolveEffectiveHeaders(headers, body),
body: body ?? null,
},
};
}

const verbose = flags.verbose && !flags.silent;

if (verbose) {
logRequest(flags.method, normalizedEndpoint, headers);
}

const response = await rawApiRequest(normalizedEndpoint, {
Expand All @@ -1198,6 +1197,25 @@ export const apiCommand = buildCommand({
headers,
});

handleResponse(stdout, response, flags);
const isError = response.status >= 400;

if (verbose) {
logResponse(response);
}

// Silent mode — no output, just exit code
if (flags.silent) {
if (isError) {
throw new OutputError(null);
}
return;
}

// Always return raw body — --fields filters it directly
if (isError) {
throw new OutputError(response.body);
}

return { data: response.body };
},
});
Loading
Loading