#10713·skypilot

`sky status -o json` writes prose to stdout, so the output is not parseable

Author: kvkarandashevCreated Sep 10, 2026Updated Sep 16, 2026

-o json (added in #8784) prints a human-readable, ANSI-coloured line to stdout ahead of the JSON payload, so json.loads(stdout) fails. stderr is empty.

bash
$ sky status -o json definitely-not-a-cluster-xyz > out.txt 2> err.txt ; echo "exit=$?"
exit=0

$ cat -A out.txt
Cluster(s) not found: ^[[1mdefinitely-not-a-cluster-xyz^[[0m.$
[]$

$ wc -c err.txt
0 err.txt

$ python -c "import json; json.loads(open('out.txt').read())"
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Note the ^[[1m / ^[[0m escapes on the machine-readable stream.

Cause

Two individually reasonable decisions meet:

  • sky/backends/backend_utils.py:4078 reports the miss through the ordinary logger. It's a library function with no knowledge of the CLI's output mode:
python
  logger.info(f'Cluster(s) not found: {bright}{clusters_str}{reset}.')
  • sky/sky_logging.py:112 sends that logger to stdout:
python
  _default_handler = EnvAwareHandler(sys.stdout)

So the exposure is general, not specific to this message: any logger.info reached on the way to the answer lands in the payload stream. "Cluster(s) not found" is just the easiest one to trigger.

What already works

Exit codes are a correct discriminator, so the only thing missing is a clean stdout:

scenario exit stdout stderr
cluster genuinely absent 0 prose + [] empty
query failed (API server unreachable) 1 empty traceback

(second row verified with SKYPILOT_API_SERVER_ENDPOINT=http://127.0.0.1:9)

That distinction is what a programmatic consumer needs and can't currently reach: without parseable JSON the remaining option is parsing the human table, which cannot express the difference between absent and unreadable — both render as "no row". We lost a long-running job to exactly that conflation. The tooling bug there was ours and is fixed on our side, but -o json is the mechanism that exists to prevent it.

Question before sending a PR

Two approaches, and I'd rather not guess:

  1. sky_logging.silent() around the machine-readable path. Covers the general case with no new machinery, is already thread-local, and preserves ERROR. Suppresses rather than redirects, so a human running -o json in a terminal loses the message.
  2. Route the handler to stderr under -o json. Nicer — the message is genuinely useful and stderr is where it belongs — but _default_handler is module-global, so doing it per-command is a larger change.

Guarding the single logger.info would close the reported case and leave the general one open, so I'd rather not do that.

Happy to send a PR for either. Which would you prefer?

Environment

  • upstream master @ 294124802, sky.__version__ == 1.0.0-dev0
  • Python 3.12, Linux 6.8
  • infra-independent: the message is emitted before any provider is consulted