Skip to content

maybeInstallCloudflareSkillsGlobally writes banner to stdout in non-interactive non-CI shells, breaking JSON output #14036

Description

@alexminza

What versions & operating system are you using?

Wrangler 4.93.0 and 4.94.0, macOS 25.5.0 (Darwin), zsh, Node 22.x. Verified at pinned tag wrangler@4.94.0.

npx envinfo --system --npmPackages '{wrangler,create-cloudflare,miniflare,@cloudflare/*}' --binaries
  System: macOS 25.5.0 (arm64)
  Binaries: Node 22.x, npm 10.x
  npmPackages: wrangler 4.94.0 (or 4.93.0)

Please provide a link to a minimal reproduction

No external repro needed — reproducible against any wrangler command that emits structured stdout, from any non-interactive non-CI shell:

# In a fresh shell (no ~/.wrangler/skills-install-metadata.json present),
# non-interactive (piped stdout), not in CI:
wrangler secret list --name <some-worker> --format json | jq '.'
# jq: parse error: Invalid numeric literal at line 1, column 11

# Same command with CI=true cleanly emits JSON:
CI=true wrangler secret list --name <some-worker> --format json | jq '.'
# [
#   { "name": "ANTHROPIC_API_KEY", "type": "secret_text" },
#
# ]

# Bare invocation shows the banner explicitly prefixing the JSON:
wrangler secret list --name <some-worker> --format json
# Cloudflare agent skills are available for: Claude Code, Cursor, Codex, Windsurf. Run wrangler in an interactive terminal to install them, or use `--install-skills` to install without prompting.
# [
#   { "name": "ANTHROPIC_API_KEY", "type": "secret_text" },
#
# ]

Describe the Bug

maybeInstallCloudflareSkillsGlobally in packages/wrangler/src/agents-skills-install.ts runs before every wrangler command and writes a banner to stdout when the predicate !force && !isInteractive() && !ci.isCI holds — i.e. non-interactive but not CI. That predicate is precisely the shape of a scripted invocation from an operator shell that pipes wrangler to jq. The banner then prefixes the command's structured output and breaks every downstream JSON consumer.

Source at the pinned tag (packages/wrangler/src/agents-skills-install.ts § maybeInstallCloudflareSkillsGlobally):

if (ci.isCI && !force) {
  // In CI environments, skip silently
  sendResultMetricsEvent({ skippedBecause: "Running in CI" });
  return;
}
// …
// In non-interactive terminals (but not CI), log a message
if (!force && !isInteractive()) {
  logger.log(
    `Cloudflare agent skills are available for: ${detectedAgents.map(({ name }) => name).join(", ")}. Run wrangler in an interactive terminal to install them, or use \`--install-skills\` to install without prompting.`
  );
  // …
  return;
}

logger.log() in packages/wrangler/src/logger.ts routes through doLog("log", args)console.log(message), and console.log writes to stdout — not stderr. So the banner contaminates the same stream that the command's --json / --format json output goes to.

The asymmetry in the predicate is the bug:

  • ci.isCI === true → banner suppressed silently. ✅
  • process.stdout.isTTY === true → interactive prompt, user can accept/decline. ✅ (banner appears, but it's a prompt, not contaminating data output)
  • ci.isCI === false && process.stdout.isTTY === false → banner written to stdout. ❌ (this issue)

The third case is exactly what happens when an operator on a developer laptop scripts wrangler:

WORKERS=$(wrangler secret list --name foo --format json | jq -r '.[].name')   # ⛅ banner leaks into $WORKERS

There is no signal to the user that a banner emitted by a separate, unrelated feature (skills auto-install) corrupted the output of secret list/containers list/deployments list/etc.

Why --format json / --json cannot suppress this:

The banner is emitted by maybeInstallCloudflareSkillsGlobally, which runs in a pre-command hook (before any command-specific code sees the format flag). The command-level flags have no way to reach into this hook to silence it.

Self-healing nature (worth being honest about): after the first interactive prompt response (or one wrangler --install-skills run), wrangler writes ~/.wrangler/skills-install-metadata.json and the banner stops appearing forever on that machine. So the user-visible impact is concentrated on:

  • Fresh developer machines.
  • Container/sandbox environments that don't preserve the metadata file across runs.
  • CI-like contexts that for some reason aren't detected as CI by ci-info (e.g. self-hosted runners with unusual env vars, scripted invocations from inside an IDE that strips CI env vars, scheduled-task contexts).
  • Anywhere CI=true is not set by convention.

Please provide any relevant error logs

Concrete failure observed when scripting wrangler from an operator shell:

$ wrangler secret list --name some-worker --format json | jq '. + {ok: true}'
jq: error (at <stdin>:1): syntax error, unexpected INVALID_CHARACTER, expecting $end (Unix shell quoting issues?)

What stdout actually contained:

Cloudflare agent skills are available for: Claude Code, Cursor, Codex, Windsurf. Run wrangler in an interactive terminal to install them, or use `--install-skills` to install without prompting.
[{"name":"ANTHROPIC_API_KEY","type":"secret_text"}, …]

jq parses the first line as input, fails. The data output is correct; it's just no longer on a stream the consumer can use.

Proposed fix

Pick one of these (in order of strength, weakest acceptable to most-correct):

  1. Write to stderr instead of stdout. The banner is operator-informational (not part of any command's data output), so it belongs on stderr. This is the smallest change and fully unblocks JSON consumers:
    // In non-interactive terminals (but not CI), log a message TO STDERR
    if (!force && !isInteractive()) {
      logger.warn(/* same message */);  // logger.warn → console.warn → stderr
      return;
    }
  2. Skip the banner whenever process.stdout.isTTY === false. A non-interactive stdout means the consumer is a script/pipe — there's no point printing an operator-facing nudge they won't see anyway:
    if (!force && (!isInteractive() || !process.stdout.isTTY)) {
      return;  // no banner, no prompt
    }
  3. Skip the banner when the pending command is JSON/format-json. Inspect args to see if the command will emit JSON; if so, skip silently. More fiddly but preserves the banner in non-interactive-shell-with-TTY edge cases.

Option 1 (move to stderr) is what I'd recommend — it preserves the banner's reach without contaminating data streams, matches the convention every other "informational notice" CLI tool follows, and requires almost no logic change. It's a 1-line fix: logger.loglogger.warn.

Alternatives considered

  1. Document CI=true as the workaround. A user-side workaround for what is clearly an upstream stdout-vs-stderr bug. Operators have no reason to learn this — and won't, until their script breaks. Already what every scripted consumer ends up doing today; this issue is asking for the underlying behaviour to stop requiring it.
  2. Set WRANGLER_WRITE_LOGS=false. Doesn't apply — that controls the on-disk debug log, not the stdout banner.
  3. Pre-create ~/.wrangler/skills-install-metadata.json in every scripted environment. Brittle; requires every consumer to know about the metadata file format and write it correctly. Not a workaround that scales.

Related upstream items

  • maybeInstallCloudflareSkillsGlobally — source of the banner.
  • logger.logdoLog("log", ...)console.log (stdout). logger.warn would route to console.warn (stderr) — same logger surface, different output stream.
  • No prior issue or PR found via gh search issues|prs on cloudflare/workers-sdk for terms "skills install banner", "agents-skills-install", or "skills install non-interactive" as of filing.

Metadata

Metadata

Labels

No labels
No labels

Type

Fields

No fields configured for Bug.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions