skillsaw decides whether to emit ANSI color solely by checking for the NO_COLOR environment variable — there is no isatty() check on stdout anywhere. skillsaw lint | less or redirecting to a file leaks raw ^[[91m escapes, which is the one place a polished CLI looks broken. The fix is the standard cascade every mature tool ships (ruff, pip, gh): --color always|never|auto > FORCE_COLOR > NO_COLOR > stream.isatty().
Once capability detection exists, the same gate enables OSC 8 hyperlinks: rule ids link to their skillsaw.org docs page and file paths become clickable file:// links, which lets the redundant "Rule docs" footer collapse in interactive terminals.
Why
Verified against main (5d2dbf9):
- Color is gated only on
NO_COLOR presence, in exactly two places: src/skillsaw/formatters/text.py:35 and _ansi_colors() at src/skillsaw/cli/_helpers.py:147.
isatty() appears only for the stderr progress indicator (_helpers.py:27) and stdin prompts in marketplace code — never stdout.
- No
--color flag in src/skillsaw/cli/_parser.py; zero FORCE_COLOR or OSC 8 (\x1b]8;;) references anywhere in src/.
- Live repro:
skillsaw lint | cat -v shows literal ^[[91m escapes in the piped output.
- The "Rule docs" footer (
text.py:80) repeats one docs URL per rule that OSC 8 links would make redundant interactively.
Proposed implementation
- Add
color_enabled(stream) to src/skillsaw/cli/_helpers.py implementing the cascade: explicit --color always|never|auto (new flag in src/skillsaw/cli/_parser.py) > FORCE_COLOR > NO_COLOR > stream.isatty(). Document the chosen precedence in docs/cli.md.
- Thread the resolved bool through
format_output/format_text (src/skillsaw/formatters/__init__.py, src/skillsaw/formatters/text.py) and the four ANSI-emitting subcommands — _lint.py, _fix.py, _explain.py, _badge.py all call _ansi_colors() today. Gate stderr notices (_helpers.py:180) on sys.stderr.isatty() separately from stdout.
- Emit OSC 8 hyperlinks only when color is enabled and
TERM != dumb: rule ids → their docs page; paths → file:// URLs. Note: #L<line> fragments on file:// are nonstandard and ignored by many terminals — harmless, but don't advertise line jumping.
- Collapse the "Rule docs" footer only in hyperlink-capable (TTY) mode; piped output keeps the footer, so anything parsing text output today is unaffected.
- Gotcha (main regression risk):
action.yml must set FORCE_COLOR=1. GitHub Actions stdout is not a TTY, so without it every Actions user silently loses colored logs the release this lands.
- Gotcha: existing tests assert color-by-default through non-TTY capture and must be updated:
tests/test_formatters.py test_text_includes_ansi_by_default (~line 194) and tests/test_integration.py test_custom_rule_warning_colors_respect_no_color (~line 2562, a subprocess-pipe assert of \x1b[ on stderr with NO_COLOR unset). Convert them to FORCE_COLOR=1 or invert the expectation.
Files to touch: src/skillsaw/cli/_parser.py, src/skillsaw/cli/_helpers.py, src/skillsaw/formatters/text.py, src/skillsaw/formatters/__init__.py, src/skillsaw/cli/_lint.py, src/skillsaw/cli/_fix.py, src/skillsaw/cli/_explain.py, src/skillsaw/cli/_badge.py, action.yml, tests/test_formatters.py, tests/test_integration.py, docs/cli.md, README.md.
Scope
Effort: M — the mechanics are simple, but the bool threads through four subcommands plus formatters, and the action.yml + test updates are mandatory, not optional polish.
Out of scope:
- Color themes or new color choices — same palette, just correct gating.
- ANSI/OSC 8 in JSON, SARIF, HTML, or the new github/markdown formatters — those stay byte-identical.
- Windows legacy console handling (modern Windows terminals enable VT processing by default).
Interactions with open PRs:
Surfaced and adversarially verified by a multi-agent codebase exploration. 🤖 Generated with Claude Code
skillsaw decides whether to emit ANSI color solely by checking for the
NO_COLORenvironment variable — there is noisatty()check on stdout anywhere.skillsaw lint | lessor redirecting to a file leaks raw^[[91mescapes, which is the one place a polished CLI looks broken. The fix is the standard cascade every mature tool ships (ruff,pip,gh):--color always|never|auto>FORCE_COLOR>NO_COLOR>stream.isatty().Once capability detection exists, the same gate enables OSC 8 hyperlinks: rule ids link to their skillsaw.org docs page and file paths become clickable
file://links, which lets the redundant "Rule docs" footer collapse in interactive terminals.Why
Verified against main (
5d2dbf9):NO_COLORpresence, in exactly two places:src/skillsaw/formatters/text.py:35and_ansi_colors()atsrc/skillsaw/cli/_helpers.py:147.isatty()appears only for the stderr progress indicator (_helpers.py:27) and stdin prompts in marketplace code — never stdout.--colorflag insrc/skillsaw/cli/_parser.py; zeroFORCE_COLORor OSC 8 (\x1b]8;;) references anywhere insrc/.skillsaw lint | cat -vshows literal^[[91mescapes in the piped output.text.py:80) repeats one docs URL per rule that OSC 8 links would make redundant interactively.Proposed implementation
color_enabled(stream)tosrc/skillsaw/cli/_helpers.pyimplementing the cascade: explicit--color always|never|auto(new flag insrc/skillsaw/cli/_parser.py) >FORCE_COLOR>NO_COLOR>stream.isatty(). Document the chosen precedence indocs/cli.md.format_output/format_text(src/skillsaw/formatters/__init__.py,src/skillsaw/formatters/text.py) and the four ANSI-emitting subcommands —_lint.py,_fix.py,_explain.py,_badge.pyall call_ansi_colors()today. Gate stderr notices (_helpers.py:180) onsys.stderr.isatty()separately from stdout.TERM != dumb: rule ids → their docs page; paths →file://URLs. Note:#L<line>fragments onfile://are nonstandard and ignored by many terminals — harmless, but don't advertise line jumping.action.ymlmust setFORCE_COLOR=1. GitHub Actions stdout is not a TTY, so without it every Actions user silently loses colored logs the release this lands.tests/test_formatters.pytest_text_includes_ansi_by_default(~line 194) andtests/test_integration.pytest_custom_rule_warning_colors_respect_no_color(~line 2562, a subprocess-pipe assert of\x1b[on stderr withNO_COLORunset). Convert them toFORCE_COLOR=1or invert the expectation.Files to touch:
src/skillsaw/cli/_parser.py,src/skillsaw/cli/_helpers.py,src/skillsaw/formatters/text.py,src/skillsaw/formatters/__init__.py,src/skillsaw/cli/_lint.py,src/skillsaw/cli/_fix.py,src/skillsaw/cli/_explain.py,src/skillsaw/cli/_badge.py,action.yml,tests/test_formatters.py,tests/test_integration.py,docs/cli.md,README.md.Scope
Effort: M — the mechanics are simple, but the bool threads through four subcommands plus formatters, and the action.yml + test updates are mandatory, not optional polish.
Out of scope:
Interactions with open PRs:
formatters/text.py,_fix.py, andtests/test_formatters.py— direct overlap; whichever lands second rebases, and the fix-hint styling should route through the same gate.formatters/__init__.pydispatch andaction.yml— both files overlap here; coordinate so the color bool only threads into the text formatter and theFORCE_COLOR=1env addition merges cleanly with Add github + markdown output formats and zero-config PR annotations in the action #408's action changes.skillsaw context: context-window cost report for agent content #398/Addskillsaw badge --large: self-contained SVG report card #400/Add git-aware diff linting:skillsaw lint --since REF#401 add other flags to_parser.py— trivial merge friction only.Surfaced and adversarially verified by a multi-agent codebase exploration. 🤖 Generated with Claude Code