diff --git a/apps/cli-docs/src/fragments/commands/cli.md b/apps/cli-docs/src/fragments/commands/cli.md index 9053b9483..241d5ce57 100644 --- a/apps/cli-docs/src/fragments/commands/cli.md +++ b/apps/cli-docs/src/fragments/commands/cli.md @@ -85,6 +85,9 @@ sentry cli defaults ca-cert /path/to/ca.pem # Disable telemetry sentry cli defaults telemetry off +# Hide Tip: hints in command output +sentry cli defaults tips off + # Clear a single default sentry cli defaults org --clear diff --git a/apps/cli-docs/src/fragments/configuration.md b/apps/cli-docs/src/fragments/configuration.md index 4a4c51840..22ec4e241 100644 --- a/apps/cli-docs/src/fragments/configuration.md +++ b/apps/cli-docs/src/fragments/configuration.md @@ -66,19 +66,31 @@ If you previously used the legacy `sentry-cli` and have a `~/.sentryclirc` file, ## Persistent Defaults -Use `sentry cli defaults` to set persistent defaults for organization, project, URL, and telemetry. These are stored in the CLI's local database and apply to all commands. +Use `sentry cli defaults` to set persistent defaults for organization, project, URL, telemetry, and tips. These are stored in the CLI's local database and apply to all commands. ```bash sentry cli defaults org my-org # Set default organization sentry cli defaults project my-project # Set default project sentry cli defaults url https://... # Set Sentry URL (self-hosted) sentry cli defaults telemetry off # Disable telemetry +sentry cli defaults tips off # Hide Tip: hints in command output sentry cli defaults # Show all current defaults sentry cli defaults org --clear # Clear a specific default ``` See [`sentry cli defaults`](./commands/cli/#sentry-cli-defaults) for full usage. +### Disabling tips + +Some commands print a `Tip: ...` hint below their output. To hide these, store the preference or set an environment variable: + +```bash +sentry cli defaults tips off # persistent +export SENTRY_DISABLE_TIPS=1 # per-session, overrides the stored preference +``` + +Only the tips are removed. Pagination hints, dry-run and cancellation notices, and the cache-age footer (`cached · 3m ago · use -f to refresh`) are still shown. + ## Global Options These flags are accepted by every command. They are not shown in individual command `--help` output, but are always available. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md index 3025e135f..dc0ea1c94 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -60,6 +60,9 @@ sentry cli defaults ca-cert /path/to/ca.pem # Disable telemetry sentry cli defaults telemetry off +# Hide Tip: hints in command output +sentry cli defaults tips off + # Clear a single default sentry cli defaults org --clear diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 38209644d..990323d20 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -19,24 +19,22 @@ import { readCaCertFile } from "../../lib/custom-ca.js"; import { parseCustomHeaders } from "../../lib/custom-headers.js"; import { clearAllDefaults, + getOnOffPreference, + ON_OFF_PREFERENCES, + type OnOffPreference, + setOnOffPreference, type DefaultsState, - getAgentSkillsPreference, getAllDefaults, getDefaultCaCert, getDefaultHeaders, getDefaultOrganization, getDefaultProject, getDefaultUrl, - getGraphicsPreference, - getTelemetryPreference, - setAgentSkillsPreference, setDefaultCaCert, setDefaultHeaders, setDefaultOrganization, setDefaultProject, setDefaultUrl, - setGraphicsPreference, - setTelemetryPreference, } from "../../lib/db/defaults.js"; import { ValidationError } from "../../lib/errors.js"; import { formatDefaultsResult } from "../../lib/formatters/human.js"; @@ -50,6 +48,7 @@ import { } from "../../lib/mutate-command.js"; import { parseBoolValue } from "../../lib/parse-bool.js"; import { computeTelemetryEffective } from "../../lib/telemetry.js"; +import { resetTipsCache } from "../../lib/tips.js"; // --------------------------------------------------------------------------- // Defaults registry — maps canonical keys to get/set/clear handlers @@ -59,9 +58,7 @@ import { computeTelemetryEffective } from "../../lib/telemetry.js"; type DefaultKey = | "organization" | "project" - | "telemetry" - | "agent-skills" - | "graphics" + | OnOffPreference | "url" | "headers" | "ca-cert"; @@ -85,87 +82,57 @@ function validateSlug(value: string, label: string): string { return trimmed; } -/** Registry of all supported defaults with their handlers */ -const DEFAULTS_REGISTRY: Record = { - organization: { - get: getDefaultOrganization, - set: (value) => setDefaultOrganization(validateSlug(value, "Organization")), - clear: () => setDefaultOrganization(null), - }, - project: { - get: getDefaultProject, - set: (value) => setDefaultProject(validateSlug(value, "Project")), - clear: () => setDefaultProject(null), - }, - telemetry: { - get: () => { - const pref = getTelemetryPreference(); - if (pref === true) { - return "on"; - } - if (pref === false) { - return "off"; - } - return null; - }, - set: (value) => { - const parsed = parseBoolValue(value); - if (parsed === null) { - throw new ValidationError( - `Invalid telemetry value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - "telemetry", - ); - } - setTelemetryPreference(parsed); - }, - clear: () => setTelemetryPreference(null), - }, - "agent-skills": { +/** Side effects to run after an on/off preference changes. */ +const ON_OFF_CHANGE_HOOKS: Partial void>> = { + tips: resetTipsCache, +}; + +/** + * Build the handler for an on/off preference. Accepts on/off, yes/no, + * true/false, or 1/0 and reports the stored value as "on", "off", or null. + */ +function onOffHandler(name: OnOffPreference): DefaultHandler { + const store = (enabled: boolean | null) => { + setOnOffPreference(name, enabled); + ON_OFF_CHANGE_HOOKS[name]?.(); + }; + return { get: () => { - const pref = getAgentSkillsPreference(); - if (pref === true) { - return "on"; + const pref = getOnOffPreference(name); + if (pref === undefined) { + return null; } - if (pref === false) { - return "off"; - } - return null; + return pref ? "on" : "off"; }, set: (value) => { const parsed = parseBoolValue(value); if (parsed === null) { throw new ValidationError( - `Invalid agent-skills value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - "agent-skills", + `Invalid ${name} value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, + name, ); } - setAgentSkillsPreference(parsed); + store(parsed); }, - clear: () => setAgentSkillsPreference(null), + clear: () => store(null), + }; +} + +/** Registry of all supported defaults with their handlers */ +const DEFAULTS_REGISTRY: Record = { + organization: { + get: getDefaultOrganization, + set: (value) => setDefaultOrganization(validateSlug(value, "Organization")), + clear: () => setDefaultOrganization(null), }, - graphics: { - get: () => { - const pref = getGraphicsPreference(); - if (pref === true) { - return "on"; - } - if (pref === false) { - return "off"; - } - return null; - }, - set: (value) => { - const parsed = parseBoolValue(value); - if (parsed === null) { - throw new ValidationError( - `Invalid graphics value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, - "graphics", - ); - } - setGraphicsPreference(parsed); - }, - clear: () => setGraphicsPreference(null), + project: { + get: getDefaultProject, + set: (value) => setDefaultProject(validateSlug(value, "Project")), + clear: () => setDefaultProject(null), }, + ...(Object.fromEntries( + ON_OFF_PREFERENCES.map((name) => [name, onOffHandler(name)]), + ) as Record), url: { get: getDefaultUrl, set: (value) => { @@ -281,6 +248,7 @@ export const defaultsCommand = buildCommand({ "sentry cli defaults telemetry off # Disable telemetry\n" + "sentry cli defaults agent-skills off # Stop installing agent skills on upgrade\n" + "sentry cli defaults graphics off # Disable inline terminal images\n" + + "sentry cli defaults tips off # Hide Tip: hints in command output\n" + "sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" + "sentry cli defaults headers 'X-IAP: t' # Set custom headers (self-hosted)\n" + "sentry cli defaults ca-cert /path/to/ca.pem # Trust a custom CA certificate\n" + @@ -295,6 +263,7 @@ export const defaultsCommand = buildCommand({ "| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" + "| `agent-skills` | Install agent skills on setup/upgrade (on/off, yes/no, true/false, 1/0) |\n" + "| `graphics` | Render images inline on capable terminals (on/off, yes/no, true/false, 1/0) |\n" + + "| `tips` | Show `Tip:` hints in command output (on/off, yes/no, true/false, 1/0). `SENTRY_DISABLE_TIPS` overrides it at runtime |\n" + "| `url` | Sentry instance URL (for self-hosted installations) |\n" + "| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |\n" + "| `ca-cert` | Path to PEM file with custom CA certificates (for corporate proxies) |", @@ -351,7 +320,7 @@ export const defaultsCommand = buildCommand({ guardNonInteractive(flags); if (!isConfirmationBypassed(flags)) { const confirmed = await log.prompt( - "This will clear all defaults (organization, project, telemetry, URL, headers, ca-cert, agent-skills, graphics). Continue?", + `This will clear all defaults (${Object.keys(DEFAULTS_REGISTRY).join(", ")}). Continue?`, { type: "confirm" }, ); if (confirmed !== true) { @@ -359,6 +328,7 @@ export const defaultsCommand = buildCommand({ } } clearAllDefaults(); + resetTipsCache(); yield new CommandOutput({ action: "clear-all" as const, defaults: getAllDefaults(), diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index fadcaf93b..e0168393f 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -31,8 +31,8 @@ import { import { CLI_VERSION } from "../../lib/constants.js"; import { getAuthConfig } from "../../lib/db/auth.js"; import { - getAgentSkillsPreference, - setAgentSkillsPreference, + getOnOffPreference, + setOnOffPreference, } from "../../lib/db/defaults.js"; import { closeDatabase, resolveXdgConfigDir } from "../../lib/db/index.js"; import { setInstallInfo } from "../../lib/db/install-info.js"; @@ -564,12 +564,12 @@ async function runConfigurationSteps(opts: ConfigStepOptions) { if (flags["no-agent-skills"]) { await bestEffort( "Recording agent skills preference", - () => setAgentSkillsPreference(false), + () => setOnOffPreference("agent-skills", false), warn, ); } const skipAgentSkills = - flags["no-agent-skills"] || getAgentSkillsPreference() === false; + flags["no-agent-skills"] || getOnOffPreference("agent-skills") === false; if (!skipAgentSkills) { await bestEffort( "Agent skills", diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 1069122c0..036ad407f 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -98,6 +98,7 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; +import { tipsSuppressed } from "../../lib/tips.js"; import { type SentryIssue, SentryIssueSchema, @@ -1482,7 +1483,7 @@ function formatIssueListHuman(result: IssueListResult): string { } // Footer tip (e.g. "Tip: Use 'sentry issue view ' ...") - if (result.footerMode) { + if (result.footerMode && !tipsSuppressed()) { parts.push(formatListFooter(result.footerMode)); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index e5ef0d76a..470748071 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -66,6 +66,7 @@ import { setLogLevel, } from "./logger.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; +import { applyTipsPreference } from "./tips.js"; /** * Parse a string input as a number. @@ -819,7 +820,12 @@ export function buildCommand< // "cached · 3m ago · use -f to refresh" when data was cached. // Skip bare `return;` paths (e.g. `--web` which opens a browser // without yielding) — no rendered output should mean no footer. - const finalHint = returned ? appendCacheHint(returned.hint) : undefined; + // `Tip:` text is stripped first when tips are disabled + // (`sentry cli defaults tips off` / SENTRY_DISABLE_TIPS); other + // notices and the cache-age staleness indicator are kept. + const finalHint = returned + ? appendCacheHint(applyTipsPreference(returned.hint)) + : undefined; await withTracing("render", "cli.command.render", () => { writeFinalization(stdout, finalHint, cleanFlags.json, renderer); }); diff --git a/packages/cli/src/lib/db/defaults.ts b/packages/cli/src/lib/db/defaults.ts index ff633dd44..3619c0f96 100644 --- a/packages/cli/src/lib/db/defaults.ts +++ b/packages/cli/src/lib/db/defaults.ts @@ -8,6 +8,7 @@ * - `defaults.url` — Sentry instance URL (for self-hosted) * - `defaults.agent-skills` — agent skill install preference (`"on"` / `"off"`) * - `defaults.graphics` — inline terminal graphics preference (`"on"` / `"off"`) + * - `defaults.tips` — `Tip:` hint preference (`"on"` / `"off"`) */ import { getDatabase } from "./index.js"; @@ -15,44 +16,52 @@ import { clearMetadata, getMetadata, setMetadata } from "./utils.js"; const DEFAULTS_ORG = "defaults.org"; const DEFAULTS_PROJECT = "defaults.project"; -const DEFAULTS_TELEMETRY = "defaults.telemetry"; const DEFAULTS_URL = "defaults.url"; const DEFAULTS_HEADERS = "defaults.headers"; const DEFAULTS_CA_CERT = "defaults.ca-cert"; -const DEFAULTS_AGENT_SKILLS = "defaults.agent-skills"; -const DEFAULTS_GRAPHICS = "defaults.graphics"; + +/** Names of the on/off preferences, in display order. */ +export const ON_OFF_PREFERENCES = [ + "telemetry", + "agent-skills", + "graphics", + "tips", +] as const; + +/** Name of an on/off preference. */ +export type OnOffPreference = (typeof ON_OFF_PREFERENCES)[number]; + +/** Metadata key an on/off preference is stored under. */ +function onOffKey(name: OnOffPreference): `defaults.${OnOffPreference}` { + return `defaults.${name}`; +} /** All metadata keys used for defaults (for bulk operations) */ const ALL_DEFAULTS_KEYS = [ DEFAULTS_ORG, DEFAULTS_PROJECT, - DEFAULTS_TELEMETRY, DEFAULTS_URL, DEFAULTS_HEADERS, DEFAULTS_CA_CERT, - DEFAULTS_AGENT_SKILLS, - DEFAULTS_GRAPHICS, + ...ON_OFF_PREFERENCES.map(onOffKey), ]; -/** State of all persistent defaults */ +/** + * State of all persistent defaults. On/off preferences are "on", "off", or + * null (= default enabled). + */ export type DefaultsState = { /** Default organization slug, or null if unset */ organization: string | null; /** Default project slug, or null if unset */ project: string | null; - /** Telemetry preference: "on", "off", or null (= default enabled) */ - telemetry: "on" | "off" | null; /** Default Sentry instance URL, or null if unset */ url: string | null; /** Custom HTTP headers for self-hosted proxy auth, or null if unset */ headers: string | null; /** Path to a PEM file with custom CA certificates, or null if unset */ "ca-cert": string | null; - /** Agent skill install preference: "on", "off", or null (= default enabled) */ - "agent-skills": "on" | "off" | null; - /** Inline terminal graphics preference: "on", "off", or null (= default enabled) */ - graphics: "on" | "off" | null; -}; +} & Record; /** Parse a raw "on" / "off" metadata value to a typed "on" | "off" | null. */ function parseOnOffValue(val: string | undefined): "on" | "off" | null { @@ -65,6 +74,35 @@ function parseOnOffValue(val: string | undefined): "on" | "off" | null { return null; } +/** + * Read a persistent on/off preference. + * + * @returns `true` if explicitly enabled, `false` if explicitly disabled, + * `undefined` if no preference is stored (callers should default to enabled) + */ +export function getOnOffPreference(name: OnOffPreference): boolean | undefined { + const key = onOffKey(name); + const value = parseOnOffValue(getMetadata(getDatabase(), [key]).get(key)); + return value === null ? undefined : value === "on"; +} + +/** + * Set or clear a persistent on/off preference. + * Pass `null` to remove it (callers will default to enabled). + */ +export function setOnOffPreference( + name: OnOffPreference, + enabled: boolean | null, +): void { + const key = onOffKey(name); + const db = getDatabase(); + if (enabled === null) { + clearMetadata(db, [key]); + } else { + setMetadata(db, { [key]: enabled ? "on" : "off" }); + } +} + // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- @@ -83,63 +121,6 @@ export function getDefaultProject(): string | null { return m.get(DEFAULTS_PROJECT) ?? null; } -/** - * Get the persistent telemetry preference. - * - * @returns `true` if explicitly enabled, `false` if explicitly disabled, - * `undefined` if no preference is stored (callers should default to enabled) - */ -export function getTelemetryPreference(): boolean | undefined { - const db = getDatabase(); - const m = getMetadata(db, [DEFAULTS_TELEMETRY]); - const val = m.get(DEFAULTS_TELEMETRY); - if (val === "on") { - return true; - } - if (val === "off") { - return false; - } - return; -} - -/** - * Get the persistent agent skill install preference. - * - * @returns `true` if explicitly enabled, `false` if explicitly disabled, - * `undefined` if no preference is stored (callers should default to enabled) - */ -export function getAgentSkillsPreference(): boolean | undefined { - const db = getDatabase(); - const m = getMetadata(db, [DEFAULTS_AGENT_SKILLS]); - const val = m.get(DEFAULTS_AGENT_SKILLS); - if (val === "on") { - return true; - } - if (val === "off") { - return false; - } - return; -} - -/** - * Get the persistent inline-graphics preference. - * - * @returns `true` if explicitly enabled, `false` if explicitly disabled, - * `undefined` if no preference is stored (callers should default to enabled) - */ -export function getGraphicsPreference(): boolean | undefined { - const db = getDatabase(); - const m = getMetadata(db, [DEFAULTS_GRAPHICS]); - const val = m.get(DEFAULTS_GRAPHICS); - if (val === "on") { - return true; - } - if (val === "off") { - return false; - } - return; -} - /** Get the default Sentry instance URL, or null if not set. */ export function getDefaultUrl(): string | null { const db = getDatabase(); @@ -177,12 +158,15 @@ export function getAllDefaults(): DefaultsState { return { organization: m.get(DEFAULTS_ORG) ?? null, project: m.get(DEFAULTS_PROJECT) ?? null, - telemetry: parseOnOffValue(m.get(DEFAULTS_TELEMETRY)), url: m.get(DEFAULTS_URL) ?? null, headers: m.get(DEFAULTS_HEADERS) ?? null, "ca-cert": m.get(DEFAULTS_CA_CERT) ?? null, - "agent-skills": parseOnOffValue(m.get(DEFAULTS_AGENT_SKILLS)), - graphics: parseOnOffValue(m.get(DEFAULTS_GRAPHICS)), + ...(Object.fromEntries( + ON_OFF_PREFERENCES.map((name) => [ + name, + parseOnOffValue(m.get(onOffKey(name))), + ]), + ) as Record), }; } @@ -210,45 +194,6 @@ export function setDefaultProject(value: string | null): void { } } -/** - * Set or clear the persistent telemetry preference. - * Pass `null` to remove the preference (callers will default to enabled). - */ -export function setTelemetryPreference(enabled: boolean | null): void { - const db = getDatabase(); - if (enabled === null) { - clearMetadata(db, [DEFAULTS_TELEMETRY]); - } else { - setMetadata(db, { [DEFAULTS_TELEMETRY]: enabled ? "on" : "off" }); - } -} - -/** - * Set or clear the persistent agent skill install preference. - * Pass `null` to remove the preference (callers will default to enabled). - */ -export function setAgentSkillsPreference(enabled: boolean | null): void { - const db = getDatabase(); - if (enabled === null) { - clearMetadata(db, [DEFAULTS_AGENT_SKILLS]); - } else { - setMetadata(db, { [DEFAULTS_AGENT_SKILLS]: enabled ? "on" : "off" }); - } -} - -/** - * Set or clear the persistent inline-graphics preference. - * Pass `null` to remove the preference (callers will default to enabled). - */ -export function setGraphicsPreference(enabled: boolean | null): void { - const db = getDatabase(); - if (enabled === null) { - clearMetadata(db, [DEFAULTS_GRAPHICS]); - } else { - setMetadata(db, { [DEFAULTS_GRAPHICS]: enabled ? "on" : "off" }); - } -} - /** Set or clear the default Sentry instance URL. Pass `null` to clear. */ export function setDefaultUrl(url: string | null): void { const db = getDatabase(); diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index e0983fa81..02ef0f324 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -206,6 +206,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Disable inline terminal graphics (kitty and sixel) on terminals that support them; the banner falls back to block art, and image attachments printed by `sentry api` are written as raw bytes instead of rendered inline. You can also set this persistently with `sentry cli defaults graphics off`.", example: "1", }, + { + name: "SENTRY_DISABLE_TIPS", + description: + "Hide `Tip: ...` hints in command output. Other notices (pagination, dry-run, cache age) are still shown. Overrides the persistent `sentry cli defaults tips` setting.", + example: "1", + }, { name: "SENTRY_NO_SIXEL", description: diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index c6038128e..f0bdb5ab2 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -31,6 +31,7 @@ import type { import { resolveOrgDisplayName } from "../api-client.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { withSerializeSpan } from "../telemetry.js"; +import { tipsSuppressed } from "../tips.js"; import { type FixabilityTier, muted } from "./colors.js"; import { colorTag, @@ -2032,7 +2033,7 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { lines.push(mdKvTable(kvRows)); // Tip footer — only when a real project exists to view - if (!dry) { + if (!dry && !tipsSuppressed()) { lines.push(""); lines.push( `*Tip: Use \`sentry project view ${result.orgSlug}/${result.project.slug}\` for details*`, @@ -2512,6 +2513,7 @@ const DEFAULT_LABELS: Record = { telemetry: "Telemetry", "agent-skills": "Agent Skills", graphics: "Graphics", + tips: "Tips", url: "URL", headers: "Headers", "ca-cert": "CA Certificate", @@ -2538,6 +2540,7 @@ function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { const telLabel = d.telemetry ?? "on (default)"; const agentSkillsLabel = d["agent-skills"] ?? "on (default)"; const graphicsLabel = d.graphics ?? "on (default)"; + const tipsLabel = d.tips ?? "on (default)"; return [ ["Organization", d.organization ? safeCodeSpan(d.organization) : notSet], @@ -2548,6 +2551,7 @@ function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { ], ["Agent Skills", escapeMarkdownInline(String(agentSkillsLabel))], ["Graphics", escapeMarkdownInline(String(graphicsLabel))], + ["Tips", escapeMarkdownInline(String(tipsLabel))], ["URL", d.url ? safeCodeSpan(d.url) : notSet], ["Headers", d.headers ? safeCodeSpan(d.headers) : notSet], ["CA Certificate", d["ca-cert"] ? safeCodeSpan(d["ca-cert"]) : notSet], diff --git a/packages/cli/src/lib/sixel.ts b/packages/cli/src/lib/sixel.ts index 659cdcc7f..2ba3f4094 100644 --- a/packages/cli/src/lib/sixel.ts +++ b/packages/cli/src/lib/sixel.ts @@ -19,7 +19,7 @@ import { execSync } from "node:child_process"; import { closeSync, openSync, readSync, writeSync } from "node:fs"; import { BANNER_SIXEL } from "../generated/banner-sixel.js"; -import { getGraphicsPreference } from "./db/defaults.js"; +import { getOnOffPreference } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { isPlainOutput, isTruthyEnv } from "./formatters/plain-detect.js"; @@ -320,7 +320,7 @@ export function canRenderSixel(): boolean { if (optedOut()) { return false; } - if (getGraphicsPreference() === false) { + if (getOnOffPreference("graphics") === false) { return false; } return detectSixelCaps().supported; @@ -338,7 +338,7 @@ export function canRenderKitty(): boolean { if (optedOut()) { return false; } - if (getGraphicsPreference() === false) { + if (getOnOffPreference("graphics") === false) { return false; } return detectSixelCaps().kitty === true; @@ -400,7 +400,7 @@ export function sixelBanner( if (optedOut()) { return; } - if (getGraphicsPreference() === false) { + if (getOnOffPreference("graphics") === false) { return; } const caps = detectSixelCaps(); diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 1364b35a2..4d3673629 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -24,7 +24,7 @@ import { SENTRY_CLI_DSN, } from "./constants.js"; import { getCustomCaCerts } from "./custom-ca.js"; -import { getTelemetryPreference } from "./db/defaults.js"; +import { getOnOffPreference } from "./db/defaults.js"; import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js"; import { type AgentInfo, @@ -139,7 +139,7 @@ export function computeTelemetryEffective(): TelemetryEffective { // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { - const pref = getTelemetryPreference(); + const pref = getOnOffPreference("telemetry"); if (pref !== undefined) { return { enabled: pref, source: "preference" }; } diff --git a/packages/cli/src/lib/tips.ts b/packages/cli/src/lib/tips.ts new file mode 100644 index 000000000..7397d13a1 --- /dev/null +++ b/packages/cli/src/lib/tips.ts @@ -0,0 +1,98 @@ +/** + * `Tip:` hint suppression via a persistent default plus an env override. + * + * `sentry cli defaults tips off` stores the preference; `SENTRY_DISABLE_TIPS` + * overrides it at runtime (env wins). Only `Tip: ...` text is removed — + * pagination, dry-run, cancellation, and other operational notices that share + * the command footer channel are preserved, and so is the cache-age footer. + * + * getsentry/toolkit#1479 + * + * @module + */ + +import { getOnOffPreference } from "./db/defaults.js"; +import { getEnv } from "./env.js"; +import { isTruthyEnv } from "./formatters/plain-detect.js"; + +/** Environment variable that disables `Tip:` hints at runtime. */ +export const TIPS_DISABLE_ENV_VAR = "SENTRY_DISABLE_TIPS"; + +/** + * Matches a `Tip: ...` sentence through the end of its line, either at the + * start of the line or after other text (e.g. `"Missing: a. Tip: Use ..."`). + */ +const TIP_PATTERN = /(^|\s+)Tip:.*$/; + +/** + * Memoized {@link tipsSuppressed} results, keyed by the active env object. + * The CLI has one env per process, so the DB is read at most once; SDK + * invocations isolated with `withEnv()` each get their own entry. + */ +const tipsCache = { byEnv: new WeakMap() }; + +/** + * Decide whether `Tip:` hints should be suppressed. Memoized per env. + * + * - A truthy `SENTRY_DISABLE_TIPS` suppresses tips and wins over the stored + * preference, using the shared truthy-env semantics (`"0"` / `"false"` / + * `""` are falsy and fall through to the preference). + * - Otherwise the stored `defaults.tips` preference applies (`off` suppresses). + * - Default: tips are shown. + */ +export function tipsSuppressed(): boolean { + const env = getEnv(); + const cached = tipsCache.byEnv.get(env); + if (cached !== undefined) { + return cached; + } + const envVal = env[TIPS_DISABLE_ENV_VAR]; + const suppressed = + (envVal !== undefined && isTruthyEnv(envVal)) || + getOnOffPreference("tips") === false; + tipsCache.byEnv.set(env, suppressed); + return suppressed; +} + +/** + * Drop memoized {@link tipsSuppressed} results. Call after the stored tips + * preference changes (`sentry cli defaults tips ...`) and between tests. + */ +export function resetTipsCache(): void { + tipsCache.byEnv = new WeakMap(); +} + +/** + * Remove `Tip: ...` text from a hint, keeping every other line intact. + * + * Lines that are entirely a tip are dropped; a tip trailing other text on the + * same line is cut off. Blank lines left at the edges are trimmed. + * + * @returns The remaining hint, or `undefined` when only tips were present + */ +export function stripTips(hint: string): string | undefined { + const kept: string[] = []; + for (const line of hint.split("\n")) { + const remainder = line.replace(TIP_PATTERN, ""); + if (remainder === line) { + kept.push(line); + } else if (remainder.trim()) { + kept.push(remainder.trimEnd()); + } + } + const result = kept.join("\n").replace(/^\n+|\n+$/g, ""); + return result.trim() ? result : undefined; +} + +/** + * Apply the user's tip preference to a command hint: strip `Tip:` text when + * tips are suppressed, otherwise return the hint unchanged. + */ +export function applyTipsPreference( + hint: string | undefined, +): string | undefined { + if (!hint) { + return hint; + } + return tipsSuppressed() ? stripTips(hint) : hint; +} diff --git a/packages/cli/test/commands/cli/defaults.test.ts b/packages/cli/test/commands/cli/defaults.test.ts index f6f973555..7595a7903 100644 --- a/packages/cli/test/commands/cli/defaults.test.ts +++ b/packages/cli/test/commands/cli/defaults.test.ts @@ -9,18 +9,17 @@ import chalk from "chalk"; import { describe, expect, test } from "vitest"; import { clearAllDefaults, - getAgentSkillsPreference, getAllDefaults, getDefaultHeaders, getDefaultOrganization, getDefaultProject, getDefaultUrl, - getTelemetryPreference, - setAgentSkillsPreference, + getOnOffPreference, + ON_OFF_PREFERENCES, setDefaultOrganization, setDefaultProject, setDefaultUrl, - setTelemetryPreference, + setOnOffPreference, } from "../../../src/lib/db/defaults.js"; import { getDatabase } from "../../../src/lib/db/index.js"; import { formatDefaultsResult } from "../../../src/lib/formatters/human.js"; @@ -73,40 +72,19 @@ describe("defaults storage", () => { expect(getDefaultProject()).toBe("proj1"); }); - test("getTelemetryPreference returns undefined when not set", () => { - expect(getTelemetryPreference()).toBeUndefined(); - }); - - test("setTelemetryPreference stores on/off", () => { - setTelemetryPreference(false); - expect(getTelemetryPreference()).toBe(false); - - setTelemetryPreference(true); - expect(getTelemetryPreference()).toBe(true); - }); - - test("setTelemetryPreference(null) clears preference", () => { - setTelemetryPreference(false); - setTelemetryPreference(null); - expect(getTelemetryPreference()).toBeUndefined(); - }); - - test("getAgentSkillsPreference returns undefined when not set", () => { - expect(getAgentSkillsPreference()).toBeUndefined(); - }); - - test("setAgentSkillsPreference stores on/off", () => { - setAgentSkillsPreference(false); - expect(getAgentSkillsPreference()).toBe(false); - - setAgentSkillsPreference(true); - expect(getAgentSkillsPreference()).toBe(true); - }); + describe.each(ON_OFF_PREFERENCES)("on/off preference %s", (name) => { + test("is undefined when not set", () => { + expect(getOnOffPreference(name)).toBeUndefined(); + }); - test("setAgentSkillsPreference(null) clears preference", () => { - setAgentSkillsPreference(false); - setAgentSkillsPreference(null); - expect(getAgentSkillsPreference()).toBeUndefined(); + test("stores on/off and clears with null", () => { + setOnOffPreference(name, false); + expect(getOnOffPreference(name)).toBe(false); + setOnOffPreference(name, true); + expect(getOnOffPreference(name)).toBe(true); + setOnOffPreference(name, null); + expect(getOnOffPreference(name)).toBeUndefined(); + }); }); test("getDefaultUrl returns null when not set", () => { @@ -127,8 +105,8 @@ describe("defaults storage", () => { test("getAllDefaults returns full state", () => { setDefaultOrganization("test-org"); setDefaultProject("test-project"); - setTelemetryPreference(false); - setAgentSkillsPreference(false); + setOnOffPreference("telemetry", false); + setOnOffPreference("agent-skills", false); setDefaultUrl("https://sentry.example.com"); const state = getAllDefaults(); @@ -141,6 +119,7 @@ describe("defaults storage", () => { headers: null, "ca-cert": null, graphics: null, + tips: null, }); }); @@ -155,20 +134,23 @@ describe("defaults storage", () => { headers: null, "ca-cert": null, graphics: null, + tips: null, }); }); test("clearAllDefaults removes everything", () => { setDefaultOrganization("org"); setDefaultProject("proj"); - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); + setOnOffPreference("tips", false); setDefaultUrl("https://example.com"); clearAllDefaults(); expect(getDefaultOrganization()).toBeNull(); expect(getDefaultProject()).toBeNull(); - expect(getTelemetryPreference()).toBeUndefined(); + expect(getOnOffPreference("telemetry")).toBeUndefined(); + expect(getOnOffPreference("tips")).toBeUndefined(); expect(getDefaultUrl()).toBeNull(); expect(getDefaultHeaders()).toBeNull(); }); @@ -234,7 +216,7 @@ describe("isTelemetryEnabled", () => { test("respects persistent preference: off", () => { setup(); try { - setTelemetryPreference(false); + setOnOffPreference("telemetry", false); expect(isTelemetryEnabled()).toBe(false); } finally { teardown(); @@ -244,7 +226,7 @@ describe("isTelemetryEnabled", () => { test("respects persistent preference: on", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); expect(isTelemetryEnabled()).toBe(true); } finally { teardown(); @@ -254,7 +236,7 @@ describe("isTelemetryEnabled", () => { test("SENTRY_CLI_NO_TELEMETRY=1 overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.SENTRY_CLI_NO_TELEMETRY = "1"; expect(isTelemetryEnabled()).toBe(false); } finally { @@ -265,7 +247,7 @@ describe("isTelemetryEnabled", () => { test("DO_NOT_TRACK=1 overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.DO_NOT_TRACK = "1"; expect(isTelemetryEnabled()).toBe(false); } finally { @@ -339,7 +321,7 @@ describe("computeTelemetryEffective", () => { test("returns preference source when set to off", () => { setup(); try { - setTelemetryPreference(false); + setOnOffPreference("telemetry", false); const result = computeTelemetryEffective(); expect(result).toEqual({ enabled: false, source: "preference" }); } finally { @@ -350,7 +332,7 @@ describe("computeTelemetryEffective", () => { test("returns preference source when set to on", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); const result = computeTelemetryEffective(); expect(result).toEqual({ enabled: true, source: "preference" }); } finally { @@ -361,7 +343,7 @@ describe("computeTelemetryEffective", () => { test("SENTRY_CLI_NO_TELEMETRY overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.SENTRY_CLI_NO_TELEMETRY = "1"; const result = computeTelemetryEffective(); expect(result.enabled).toBe(false); @@ -374,7 +356,7 @@ describe("computeTelemetryEffective", () => { test("DO_NOT_TRACK overrides preference", () => { setup(); try { - setTelemetryPreference(true); + setOnOffPreference("telemetry", true); process.env.DO_NOT_TRACK = "1"; const result = computeTelemetryEffective(); expect(result.enabled).toBe(false); @@ -417,6 +399,7 @@ describe("formatDefaultsResult", () => { project: "my-proj", telemetry: "off", graphics: "off", + tips: "off", url: "https://sentry.example.com", }, telemetryEffective: { enabled: false, source: "preference" }, @@ -426,6 +409,7 @@ describe("formatDefaultsResult", () => { expect(plain).toContain("my-proj"); expect(plain).toContain("off"); expect(plain).toContain("Graphics"); + expect(plain).toContain("Tips"); expect(plain).toContain("sentry.example.com"); }); diff --git a/packages/cli/test/commands/cli/setup.test.ts b/packages/cli/test/commands/cli/setup.test.ts index f8861b4fd..b09a8c431 100644 --- a/packages/cli/test/commands/cli/setup.test.ts +++ b/packages/cli/test/commands/cli/setup.test.ts @@ -45,8 +45,8 @@ import { app } from "../../../src/app.js"; import type { SentryContext } from "../../../src/context.js"; import { getAuthConfig, setAuthToken } from "../../../src/lib/db/auth.js"; import { - getAgentSkillsPreference, - setAgentSkillsPreference, + getOnOffPreference, + setOnOffPreference, } from "../../../src/lib/db/defaults.js"; import { clearInstallInfo, @@ -938,7 +938,7 @@ describe("sentry cli setup", () => { beforeEach(() => { // Reset the persisted preference — the config DB is shared across tests // in this file, so a prior --no-agent-skills run would otherwise leak. - setAgentSkillsPreference(null); + setOnOffPreference("agent-skills", null); originalFetch = globalThis.fetch; mockFetch( async () => @@ -948,7 +948,7 @@ describe("sentry cli setup", () => { afterEach(() => { globalThis.fetch = originalFetch; - setAgentSkillsPreference(null); + setOnOffPreference("agent-skills", null); }); test("installs agent skills when Claude Code is detected", async () => { @@ -1123,12 +1123,12 @@ describe("sentry cli setup", () => { context, ); - expect(getAgentSkillsPreference()).toBe(false); + expect(getOnOffPreference("agent-skills")).toBe(false); }); test("honors a persisted opt-out without the flag (upgrade scenario)", async () => { // Simulate a prior `--no-agent-skills` run having stored the preference. - setAgentSkillsPreference(false); + setOnOffPreference("agent-skills", false); mkdirSync(join(testDir, ".claude"), { recursive: true }); const { context, getOutput, restore } = createMockContext({ diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts index d8a19c112..195f91e31 100644 --- a/packages/cli/test/helpers.ts +++ b/packages/cli/test/helpers.ts @@ -15,6 +15,7 @@ import { resetIdentityFingerprintCache, } from "../src/lib/db/auth.js"; import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../src/lib/db/index.js"; +import { resetTipsCache } from "../src/lib/tips.js"; // re-exporting a single constant, not a barrel export { TEST_TMP_DIR } from "./constants.js"; @@ -116,6 +117,7 @@ export function useTestConfigDir( resetAuthRowCache(); resetHasStoredCredsCache(); resetIdentityFingerprintCache(); + resetTipsCache(); dir = await createTestConfigDir(prefix, options); process.env[CONFIG_DIR_ENV_VAR] = dir; }); @@ -126,6 +128,7 @@ export function useTestConfigDir( resetAuthRowCache(); resetHasStoredCredsCache(); resetIdentityFingerprintCache(); + resetTipsCache(); // Always restore the previous value — never delete. // Deleting process.env.SENTRY_CONFIG_DIR causes failures in test files // that load after this afterEach runs, because their module-level code diff --git a/packages/cli/test/lib/command.test.ts b/packages/cli/test/lib/command.test.ts index 4b269c9e5..7283321fb 100644 --- a/packages/cli/test/lib/command.test.ts +++ b/packages/cli/test/lib/command.test.ts @@ -26,6 +26,7 @@ import { numberParser, VERBOSE_FLAG, } from "../../src/lib/command.js"; +import { setEnv } from "../../src/lib/env.js"; import { EXIT, OutputError } from "../../src/lib/errors.js"; import { CommandOutput } from "../../src/lib/formatters/output.js"; import { @@ -1281,6 +1282,45 @@ describe("buildCommand return-based output", () => { expect(jsonOutput).toEqual({ value: 42 }); }); + test("strips only Tip: lines from the hint when tips are disabled", async () => { + const command = buildCommand< + { json: boolean; fields?: string[] }, + [], + TestContext + >({ + auth: false, + docs: { brief: "Test" }, + output: { + human: (d: { value: number }) => `Value: ${d.value}`, + }, + parameters: {}, + async *func(this: TestContext) { + yield new CommandOutput({ value: 42 }); + return { + hint: "Showing 10 of 25 items\nTip: Use 'sentry thing view ' for details", + }; + }, + }); + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + setEnv({ ...process.env, SENTRY_DISABLE_TIPS: "1" }); + try { + await run(app, ["test"], ctx as TestContext); + } finally { + setEnv(process.env); + } + + const output = ctx.output.join(""); + expect(output).toContain("Value: 42"); + expect(output).toContain("Showing 10 of 25 items"); + expect(output).not.toContain("Tip:"); + }); + test("void return does nothing (no crash)", async () => { let executed = false; diff --git a/packages/cli/test/lib/tips.test.ts b/packages/cli/test/lib/tips.test.ts new file mode 100644 index 000000000..12584b99a --- /dev/null +++ b/packages/cli/test/lib/tips.test.ts @@ -0,0 +1,138 @@ +/** + * Tip-hint suppression tests (getsentry/toolkit#1479). + * + * Covers `tipsSuppressed()` precedence between `SENTRY_DISABLE_TIPS` and the + * stored `defaults.tips` preference, and `stripTips()` keeping non-tip lines. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { setOnOffPreference } from "../../src/lib/db/defaults.js"; +import { setEnv } from "../../src/lib/env.js"; +import { + applyTipsPreference, + resetTipsCache, + stripTips, + tipsSuppressed, +} from "../../src/lib/tips.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-tips-"); + +/** Point getEnv() at process.env with SENTRY_DISABLE_TIPS set or removed. */ +function setTipsEnv(value?: string): void { + const env = { ...process.env }; + delete env.SENTRY_DISABLE_TIPS; + setEnv(value === undefined ? env : { ...env, SENTRY_DISABLE_TIPS: value }); +} + +describe("tipsSuppressed", () => { + beforeEach(() => { + setTipsEnv(); + }); + + afterEach(() => { + setEnv(process.env); + }); + + test("shows tips by default (no env, no preference)", () => { + expect(tipsSuppressed()).toBe(false); + }); + + test("suppresses tips when the stored preference is off", () => { + setOnOffPreference("tips", false); + expect(tipsSuppressed()).toBe(true); + }); + + test("shows tips when the stored preference is on", () => { + setOnOffPreference("tips", true); + expect(tipsSuppressed()).toBe(false); + }); + + test("memoizes per env until the cache is reset", () => { + expect(tipsSuppressed()).toBe(false); + setOnOffPreference("tips", false); + expect(tipsSuppressed()).toBe(false); + resetTipsCache(); + expect(tipsSuppressed()).toBe(true); + }); + + test("truthy SENTRY_DISABLE_TIPS wins over the stored preference", () => { + setOnOffPreference("tips", true); + for (const val of ["1", "true", "yes"]) { + setTipsEnv(val); + expect(tipsSuppressed()).toBe(true); + } + }); + + test("falsy SENTRY_DISABLE_TIPS falls through to the stored preference", () => { + for (const val of ["0", "false", ""]) { + setTipsEnv(val); + setOnOffPreference("tips", null); + resetTipsCache(); + expect(tipsSuppressed()).toBe(false); + setOnOffPreference("tips", false); + resetTipsCache(); + expect(tipsSuppressed()).toBe(true); + } + }); +}); + +describe("stripTips", () => { + test("drops a hint that is only a tip", () => { + expect( + stripTips( + "Tip: Use 'sentry issue explain CLI-1' for AI root cause analysis", + ), + ).toBeUndefined(); + }); + + test("keeps non-tip lines", () => { + expect( + stripTips( + "Showing 10 of 25 organizations\nTip: Use 'sentry org view ' for details", + ), + ).toBe("Showing 10 of 25 organizations"); + }); + + test("cuts a tip that trails other text on the same line", () => { + expect( + stripTips( + "Missing: web, api. Tip: Use 'sentry project list acme/' to see all projects", + ), + ).toBe("Missing: web, api."); + }); + + test("leaves hints without tips unchanged", () => { + const hint = "No issues found.\n\nDetected from .env file"; + expect(stripTips(hint)).toBe(hint); + }); + + test("trims blank lines left at the edges", () => { + expect( + stripTips("No projects found.\n\nTip: Use 'sentry project list'"), + ).toBe("No projects found."); + }); +}); + +describe("applyTipsPreference", () => { + afterEach(() => { + setEnv(process.env); + }); + + test("returns the hint unchanged when tips are enabled", () => { + setTipsEnv(); + const hint = "Showing 5 items\nTip: Use 'sentry thing view '"; + expect(applyTipsPreference(hint)).toBe(hint); + }); + + test("strips tips when SENTRY_DISABLE_TIPS is set", () => { + setTipsEnv("1"); + expect( + applyTipsPreference("Showing 5 items\nTip: Use 'sentry thing view '"), + ).toBe("Showing 5 items"); + }); + + test("passes undefined through", () => { + expect(applyTipsPreference(undefined)).toBeUndefined(); + }); +});