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):
- 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;
}
- 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
}
- 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.log → logger.warn.
Alternatives considered
- 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.
- Set
WRANGLER_WRITE_LOGS=false. Doesn't apply — that controls the on-disk debug log, not the stdout banner.
- 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.log — doLog("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.
What versions & operating system are you using?
Wrangler
4.93.0and4.94.0, macOS 25.5.0 (Darwin), zsh, Node 22.x. Verified at pinned tagwrangler@4.94.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:
Describe the Bug
maybeInstallCloudflareSkillsGloballyinpackages/wrangler/src/agents-skills-install.tsruns before every wrangler command and writes a banner to stdout when the predicate!force && !isInteractive() && !ci.isCIholds — i.e. non-interactive but not CI. That predicate is precisely the shape of a scripted invocation from an operator shell that pipes wrangler tojq. 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):logger.log()inpackages/wrangler/src/logger.tsroutes throughdoLog("log", args)→console.log(message), andconsole.logwrites to stdout — not stderr. So the banner contaminates the same stream that the command's--json/--format jsonoutput 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:
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/--jsoncannot 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-skillsrun), wrangler writes~/.wrangler/skills-install-metadata.jsonand the banner stops appearing forever on that machine. So the user-visible impact is concentrated on: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).CI=trueis not set by convention.Please provide any relevant error logs
Concrete failure observed when scripting wrangler from an operator shell:
What stdout actually contained:
jqparses 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):
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:argsto 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.log→logger.warn.Alternatives considered
CI=trueas 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.WRANGLER_WRITE_LOGS=false. Doesn't apply — that controls the on-disk debug log, not the stdout banner.~/.wrangler/skills-install-metadata.jsonin 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.log—doLog("log", ...)→console.log(stdout).logger.warnwould route toconsole.warn(stderr) — same logger surface, different output stream.gh search issues|prsoncloudflare/workers-sdkfor terms "skills install banner", "agents-skills-install", or "skills install non-interactive" as of filing.