Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,7 @@ All command docs and skill files are generated via `pnpm run generate:docs`. Thi

- **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`).
- **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale.
- **Hints name the running program**: the same code runs as `sentry`, `npx sentry`, and `sentry-mcp`, so write a hint's command with the `` sentry`…` `` tag from `src/lib/app-identity.ts`, without the program name (`` sentry`issue list ${org}/` ``), inside the function that builds the hint. A command held in a variable goes through `asInvocationCommand(command)`. A module-level usage line (`USAGE_HINT`, `commandPrefix`) is fine when the code that prints it wraps it, as `ContextError`, `ResolutionError`, `buildValidationMessage`, and `org-list` do. Command help (`docs`, `fullDescription`, `brief`, `examples`) stays `sentry …`. Lint (`sentry-cli/no-hardcoded-sentry-command`) flags a bare `sentry <route>` (routes come from `src/command-manifest.ts`), and `pnpm run check:doc-examples` checks each tag's command and flags against the route tree.
- **Hints name the running program**: the same code runs as `sentry`, `npx sentry`, and `sentry-mcp`, so write a hint's command with the `` sentry`…` `` tag from `src/lib/app-identity.ts`, without the program name (`` sentry`issue list ${org}/` ``), inside the function that builds the hint. The tag inserts interpolated values as they are and nothing rewrites a hint later, so errors such as `ContextError` print usage lines as given. Keep a command in a constant or table as a `LazyCommand`, `` const usageHint = () => sentry`issue view <id>` ``, and call it where the hint is shown: the entry point registers the program only after the command modules load (`test/lib/app-identity-import.test.ts` checks that no hint is built at import). Command help (`docs`, `fullDescription`, `brief`, `examples`) stays `sentry …`. Lint (`sentry-cli/no-hardcoded-sentry-command`) flags a bare `sentry <route>` (routes come from `src/command-manifest.ts`), and `pnpm run check:doc-examples` checks each tag's command and flags against the route tree.
- **Examples belong to the command**: add them to `docs.examples` (`{ description, command }[]`, see `src/commands/agent-conversation/list.ts`). They render in `--help`, the generated docs, and the skill references. Don't hand-write examples in `fullDescription` (an `Examples:` block, or indented `sentry …` lines under any label) or an Examples heading in a fragment; the `sentry-cli/no-examples-in-full-description` lint rule and `pnpm run check:fragments` reject both.
- **Target syntax belongs to the positional**: describe accepted target forms in the positional `brief` (use `ORG_ALL_TARGET_BRIEF` or `PROJECT_TARGET_BRIEF` from `src/lib/list-command.ts` for `[<org>/][<project>]` targets), not in a `Target patterns:` block. The brief renders in `--help`, the generated docs, and the skill references.
- Use fragments in `apps/cli-docs/src/fragments/commands/` only for prose the command metadata can't carry: guides, permissions, caveats.
Expand Down
37 changes: 2 additions & 35 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,11 +396,6 @@ function namesSentryCommand(text) {
return false;
}

/** A whole `sentry <command>`, such as a usage line or a command prefix. */
function isWholeSentryCommand(text) {
return namesSentryCommand(text) && text.startsWith("sentry ");
}

/** The tag that builds a command hint: `` sentry`issue list` ``. */
function isSentryTag(node) {
return (
Expand All @@ -410,15 +405,6 @@ function isSentryTag(node) {
);
}

/** A call of the runtime form, for commands held in variables. */
function isInvocationCall(node) {
return (
node.type === "CallExpression" &&
node.callee.type === "Identifier" &&
node.callee.name === "asInvocationCommand"
);
}

/** Text of a template literal, each interpolation as one word. */
function templateText(node) {
return node.quasis
Expand Down Expand Up @@ -446,7 +432,7 @@ function hasFunctionAncestor(node) {
function isInvocationSafe(node) {
for (let current = node.parent; current; current = current.parent) {
if (FUNCTION_TYPES.has(current.type)) return false;
if (isSentryTag(current) || isInvocationCall(current)) return true;
if (isSentryTag(current)) return true;
if (
current.type === "Property" &&
HELP_TEXT_KEYS.has(current.key?.name ?? current.key?.value)
Expand Down Expand Up @@ -911,9 +897,6 @@ const rules = {
if (!file.includes("/src/")) return {};
const check = (node, text) => {
if (!namesSentryCommand(text) || isInvocationSafe(node)) return;
// A module-level constant that holds a whole command is data: the
// code that prints it wraps it, as ContextError and org-list do.
if (isWholeSentryCommand(text) && !hasFunctionAncestor(node)) return;
report(node);
};
// The running program is known only once an entry point has started,
Expand All @@ -922,26 +905,10 @@ const rules = {
if (!hasFunctionAncestor(node))
report(
node,
"Build the hint inside the function that prints it, not at module load: the running program is not known yet.",
"Build the hint when it is shown, not at module load: the running program is not known yet. Keep a command in a constant or table as `() => sentry`…``.",
);
};
return {
CallExpression(node) {
if (!isInvocationCall(node)) return;
checkModuleLevel(node);
const [arg] = node.arguments;
const text =
arg?.type === "Literal"
? arg.value
: arg?.type === "TemplateLiteral"
? templateText(arg)
: undefined;
if (typeof text === "string" && text.startsWith("sentry "))
report(
node,
"Write a literal command with the sentry`…` tag; asInvocationCommand() is for commands held in variables.",
);
},
TaggedTemplateExpression(node) {
if (!isSentryTag(node)) return;
checkModuleLevel(node);
Expand Down
12 changes: 6 additions & 6 deletions packages/cli/src/commands/agent-conversation/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import type { SentryContext } from "../../context.js";
import { getConversationSpans } from "../../lib/api-client.js";
import { asInvocationCommand } from "../../lib/app-identity.js";
import { buildCommand } from "../../lib/command.js";
import { ORG_TARGET } from "../../lib/completion-target.js";
import {
Expand All @@ -33,14 +32,15 @@ import {
parseOrgInput,
resolveOrgInput,
} from "../../lib/target-positional.js";
import { sentry } from "../../lib/app-identity.js";

type ViewFlags = {
readonly json: boolean;
readonly fresh: boolean;
};

const USAGE = "[<org>/]<conversation-id>";
const USAGE_HINT = `sentry agent-conversation view ${USAGE}`;
const usageHint = () => sentry`agent-conversation view ${USAGE}`;

const log = logger.withTag("agent-conversation.view");

Expand Down Expand Up @@ -82,7 +82,7 @@ export function parseConversationArguments(
args: readonly string[],
): ConversationInput {
const parsed = parseIdArguments(args, ID_KINDS.conversation, {
usageHint: USAGE_HINT,
usageHint: usageHint(),
multiple: true,
});
let { target, ids } = parsed;
Expand All @@ -92,12 +92,12 @@ export function parseConversationArguments(
}
if (ids.length > 1) {
throw new ValidationError(
`View one conversation at a time: ${asInvocationCommand(USAGE_HINT)}`,
`View one conversation at a time: ${usageHint()}`,
"conversation-id",
);
}
const [conversationId = ""] = ids;
const org = parseOrgInput(target, USAGE_HINT);
const org = parseOrgInput(target, usageHint());
return {
conversationId,
org: parsed.baseUrl ? { ...org, baseUrl: parsed.baseUrl } : org,
Expand Down Expand Up @@ -147,7 +147,7 @@ export const viewCommand = buildCommand({
const { org } = await resolveOrgInput(
input.org,
context.cwd,
USAGE_HINT,
usageHint(),
);
return { org, conversationId: input.conversationId };
},
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/alert/arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
boundPositional,
} from "../../lib/positional-parse.js";
import { applySentryUrlContext } from "../../lib/sentry-url-parser.js";
import type { LazyCommand } from "../../lib/app-identity.js";

/** A rule ID or name and the target to find it in. */
export type AlertRuleRef = {
Expand Down Expand Up @@ -58,11 +59,11 @@ export function alertRuleParameter(options: {
readonly brief: string;
readonly kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET;
readonly split: RuleArgSplitter;
readonly usageHint: string;
readonly usageHint: LazyCommand;
}) {
const { placeholder, brief, kind, split, usageHint } = options;
const parse = (raw: string | undefined) => {
const { ref, targetArg } = split(raw ?? "", usageHint);
const { ref, targetArg } = split(raw ?? "", usageHint());
return { ref, ...parseOrgProjectInput(targetArg) };
};
return {
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/alert/issues/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ import type {
IssueAlertCreateOutput,
} from "../../../output-schemas/alert.js";
import { IssueAlertCreateOutputSchema } from "../../../generated/output-schemas/alert.js";
import { sentry } from "../../../lib/app-identity.js";

const USAGE_HINT =
"sentry alert issues create <target> --name <name> --condition <json> --action <json>";
const usageHint = () =>
sentry`alert issues create <target> --name <name> --condition <json> --action <json>`;

type CreateFlags = {
readonly name: string;
Expand Down Expand Up @@ -86,11 +87,11 @@ async function resolveCreateTarget(
): Promise<ResolvedTarget> {
const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
usageHint: usageHint(),
});
const [target, ...others] = targets;
if (!target) {
throw new ContextError("Organization and project", USAGE_HINT);
throw new ContextError("Organization and project", usageHint());
}
if (others.length > 0) {
throw new ValidationError(
Expand Down
15 changes: 8 additions & 7 deletions packages/cli/src/commands/alert/issues/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import type { AlertRuleArgument } from "../arguments.js";
import { issueRuleParameter, resolveIssueAlertRule } from "./rule-resolve.js";
import type { IssueAlertDeleteOutput } from "../../../output-schemas/alert.js";
import { IssueAlertDeleteOutputSchema } from "../../../generated/output-schemas/alert.js";
import { sentry } from "../../../lib/app-identity.js";

const USAGE_HINT =
"sentry alert issues delete <org>/<project>/<rule-id-or-name>";
const usageHint = () =>
sentry`alert issues delete <org>/<project>/<rule-id-or-name>`;

type DeleteFlags = {
readonly yes: boolean;
Expand Down Expand Up @@ -69,7 +70,7 @@ export const deleteCommand = buildDeleteCommand({
positional: {
kind: "tuple",
parameters: [
issueRuleParameter(USAGE_HINT, "Rule id or name (same as view)"),
issueRuleParameter(usageHint, "Rule id or name (same as view)"),
],
},
},
Expand All @@ -80,7 +81,7 @@ export const deleteCommand = buildDeleteCommand({
) {
const { cwd } = this;
const { ref, target: parsed } = await ruleArg.resolved;
requireExplicitTarget(parsed, "Issue alert target", USAGE_HINT);
requireExplicitTarget(parsed, "Issue alert target", usageHint());
if (parsed.type !== "explicit") {
throw new ValidationError(
"Issue alert delete requires an explicit <org>/<project>/<rule-id-or-name> target.",
Expand All @@ -90,16 +91,16 @@ export const deleteCommand = buildDeleteCommand({

const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
usageHint: usageHint(),
});
if (targets.length === 0) {
throw new ContextError("Organization and project", USAGE_HINT);
throw new ContextError("Organization and project", usageHint());
}

const { target, rule } = await resolveIssueAlertRule(
targets,
ref,
USAGE_HINT,
usageHint(),
);

const key = `${target.org}/${target.project}/${rule.id}`;
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/commands/alert/issues/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ import type { AlertRuleArgument } from "../arguments.js";
import { issueRuleParameter, resolveIssueAlertRule } from "./rule-resolve.js";
import type { IssueAlertEditOutput } from "../../../output-schemas/alert.js";
import { IssueAlertEditOutputSchema } from "../../../generated/output-schemas/alert.js";
import { sentry } from "../../../lib/app-identity.js";

const USAGE_HINT =
"sentry alert issues edit <org>/<project>/<rule-id-or-name> --name <name> | --status active|disabled";
const usageHint = () =>
sentry`alert issues edit <org>/<project>/<rule-id-or-name> --name <name> | --status active|disabled`;

type EditFlags = {
readonly name?: string;
Expand Down Expand Up @@ -230,7 +231,7 @@ export const editCommand = buildCommand({
positional: {
kind: "tuple",
parameters: [
issueRuleParameter(USAGE_HINT, "Rule id or name (same as view)"),
issueRuleParameter(usageHint, "Rule id or name (same as view)"),
],
},
flags: {
Expand Down Expand Up @@ -312,16 +313,16 @@ export const editCommand = buildCommand({
const { ref, target: parsed } = await ruleArg.resolved;
const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
usageHint: usageHint(),
});
if (targets.length === 0) {
throw new ContextError("Organization and project", USAGE_HINT);
throw new ContextError("Organization and project", usageHint());
}

const { target, rule } = await resolveIssueAlertRule(
targets,
ref,
USAGE_HINT,
usageHint(),
);

const body = {
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/commands/alert/issues/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,12 @@ import {
fetchAlertRulesWithBudget,
throwAlertListFetchFailure,
} from "../list-utils.js";
import { sentry } from "../../../lib/app-identity.js";

/** Command key for pagination cursor storage */
export const PAGINATION_KEY = "alert-issues-list";

const USAGE_HINT = "sentry alert issues list <org>/<project>";
const usageHint = () => sentry`alert issues list <org>/<project>`;

type ListFlags = {
readonly web: boolean;
Expand Down Expand Up @@ -122,7 +123,7 @@ type IssueAlertListResult = ListResult<IssueAlertRule> & {
const issueAlertListMeta: ListCommandMeta = {
paginationKey: PAGINATION_KEY,
entityPlural: "issue alert rules",
commandPrefix: "sentry alert issues list",
commandPrefix: () => sentry`alert issues list`,
};

// Fetch helpers
Expand Down Expand Up @@ -186,11 +187,11 @@ async function resolveWebUrl(

const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
usageHint: usageHint(),
projectSearchResolution,
});
if (targets.length === 0) {
throw new ContextError("Organization and project", USAGE_HINT);
throw new ContextError("Organization and project", usageHint());
}

const orgs = [...new Set(targets.map((t) => t.org))];
Expand Down Expand Up @@ -226,11 +227,11 @@ async function handleResolvedTargets(

const { targets, footer, detectedDsns } = await resolveProjectBoundTargets(
parsed,
{ cwd, usageHint: USAGE_HINT, projectSearchResolution },
{ cwd, usageHint: usageHint(), projectSearchResolution },
);

if (targets.length === 0) {
throw new ContextError("Organization and project", USAGE_HINT);
throw new ContextError("Organization and project", usageHint());
}

const contextKey = buildMultiTargetContextKey(targets, {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/commands/alert/issues/rule-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
isNotFoundApiError,
listIssueAlertsPaginated,
} from "../../../lib/api-client.js";
import { asInvocationCommand, sentry } from "../../../lib/app-identity.js";
import { type LazyCommand, sentry } from "../../../lib/app-identity.js";
import { ORG_PROJECT_TARGET } from "../../../lib/completion-target.js";
import { ResolutionError, ValidationError } from "../../../lib/errors.js";
import { fuzzyMatch } from "../../../lib/fuzzy.js";
Expand Down Expand Up @@ -40,7 +40,7 @@ export function parseIssueRuleArg(
const trimmed = arg.trim();
if (!trimmed) {
throw new ValidationError(
`Rule id or name is required.\nUse: ${asInvocationCommand(usageHint)}`,
`Rule id or name is required.\nUse: ${usageHint}`,
"rule",
);
}
Expand All @@ -52,7 +52,7 @@ export function parseIssueRuleArg(
if (slashCount === 1) {
throw new ValidationError(
`Missing rule id or name after the project (got '${trimmed}').\n` +
`Use: ${asInvocationCommand(usageHint)}\n` +
`Use: ${usageHint}\n` +
`Example: ${trimmed}/<rule-id-or-name>`,
"rule",
);
Expand All @@ -63,7 +63,7 @@ export function parseIssueRuleArg(
const ref = trimmed.slice(lastSlash + 1).trim();
if (!ref) {
throw new ValidationError(
`Invalid rule reference '${arg}'.\nUse: ${asInvocationCommand(usageHint)}`,
`Invalid rule reference '${arg}'.\nUse: ${usageHint}`,
"rule",
);
}
Expand All @@ -74,7 +74,7 @@ export function parseIssueRuleArg(
* The `<org>/<project>/<rule-id-or-name>` parameter of an issue alert
* command, parsed with {@link parseIssueRuleArg} before the command runs.
*/
export function issueRuleParameter(usageHint: string, brief: string) {
export function issueRuleParameter(usageHint: LazyCommand, brief: string) {
return alertRuleParameter({
placeholder: "org/project/rule-id-or-name",
brief,
Expand Down
Loading
Loading