22 "argparse" ,
33 "collections" ,
44 "collections.abc" ,
5+ "os" ,
56 "pathlib" ,
6- "rich" ,
7- "rich.columns" ,
8- "rich.panel" ,
97 "sp_repo_review._compat" ,
108 "sp_repo_review.checks" ,
119 "sp_repo_review.checks.ruff" ,
1210 "sys" ,
11+ "typing" ,
1312]
1413
1514import argparse
15+ import importlib
1616import importlib .resources
1717import json
18+ import os
1819import sys
1920from collections .abc import Iterator , Mapping
21+ from importlib .util import find_spec
2022from pathlib import Path
2123
22- from rich import print
23- from rich .columns import Columns
24- from rich .panel import Panel
25-
2624from sp_repo_review ._compat import tomllib
2725from sp_repo_review .checks .ruff import get_rule_selection , ruff
2826
4240with RESOURCE_DIR .joinpath ("ignore.json" ).open (encoding = "utf-8" ) as f :
4341 IGNORE_INFO = json .load (f )
4442
43+ # Tool-specific agent variables
44+ # Based on https://github.com/agentsmd/agents.md/issues/136
45+ _AGENT_VARS = [
46+ "AGENT" , # Pi, Goose, Amp
47+ "CLAUDECODE" ,
48+ "CURSOR_AGENT" ,
49+ "CLINE_ACTIVE" ,
50+ "GEMINI_CLI" ,
51+ "CODEX_SANDBOX" ,
52+ "AUGMENT_AGENT" ,
53+ "TRAE_AI_SHELL_ID" ,
54+ "OPENCODE_CLIENT" ,
55+ ]
56+
57+
58+ def _is_agent_environment () -> bool :
59+ """Check if running from an AI coding agent using env vars."""
60+ return any (os .environ .get (var ) for var in _AGENT_VARS )
61+
62+
63+ def _resolve_format (format_arg : str ) -> str :
64+ """Resolve 'auto' format to either 'rich' or 'plain'."""
65+ if format_arg != "auto" :
66+ return format_arg
67+
68+ if _is_agent_environment ():
69+ return "plain"
70+
71+ return "rich" if _has_rich () else "plain"
72+
73+
74+ def _has_rich () -> bool :
75+ return find_spec ("rich" ) is not None
76+
77+
78+ def _print_each_plain (items : Mapping [str , str ], indent : int = 2 ) -> Iterator [str ]:
79+ """Generate plain text formatted rule lines."""
80+ size = max (len (k ) for k in items ) if items else 0
81+ for k , v in items .items ():
82+ yield f'{ " " * indent } "{ k } ",{ " " * (size - len (k ))} # { v } '
83+
4584
46- def print_each (items : Mapping [str , str ]) -> Iterator [str ]:
85+ def _print_each_rich (items : Mapping [str , str ]) -> Iterator [str ]:
86+ """Generate rich formatted rule lines."""
4787 size = max (len (k ) for k in items ) if items else 0
4888 for k , v in items .items ():
4989 kk = f'[green]"{ k } "[/green],'
5090 yield f" { kk :{size + 18 }} [dim]# { v } [/dim]"
5191
5292
53- def process_dir (path : Path ) -> None :
93+ def _output_error (fmt : str , message : str ) -> None :
94+ """Output error message in appropriate format."""
95+ if fmt == "rich" :
96+ import rich
97+
98+ rich .print (message , file = sys .stderr )
99+ else :
100+ print (message , file = sys .stderr )
101+
102+
103+ def _print_output_rich (
104+ selected_items : dict [str , str ],
105+ libs_items : dict [str , str ],
106+ spec_items : dict [str , str ],
107+ unselected_items : dict [str , str ],
108+ ) -> None :
109+ """Print rich formatted output."""
110+ import rich .columns
111+ import rich .panel
112+
113+ panel_sel = rich .panel .Panel (
114+ "\n " .join (_print_each_rich (selected_items )),
115+ title = "Selected" ,
116+ border_style = "green" ,
117+ )
118+ panel_lib = rich .panel .Panel (
119+ "\n " .join (_print_each_rich (libs_items )),
120+ title = "Library specific" ,
121+ border_style = "yellow" ,
122+ )
123+ panel_spec = rich .panel .Panel (
124+ "\n " .join (_print_each_rich (spec_items )),
125+ title = "Specialized" ,
126+ border_style = "yellow" ,
127+ )
128+ uns = "\n " .join (_print_each_rich (unselected_items ))
129+
130+ rich .print (rich .columns .Columns ([panel_sel , panel_lib , panel_spec ]))
131+ if uns :
132+ rich .print ("[red]Unselected [dim](copy and paste ready)" )
133+ rich .print (uns )
134+
135+
136+ def _print_output_plain (
137+ selected_items : dict [str , str ],
138+ libs_items : dict [str , str ],
139+ spec_items : dict [str , str ],
140+ unselected_items : dict [str , str ],
141+ ) -> None :
142+ """Print plain formatted output."""
143+ print ("Selected:" )
144+ for item in _print_each_plain (selected_items ):
145+ print (item )
146+
147+ if libs_items :
148+ print ("\n Library specific:" )
149+ for item in _print_each_plain (libs_items ):
150+ print (item )
151+
152+ if spec_items :
153+ print ("\n Specialized:" )
154+ for item in _print_each_plain (spec_items ):
155+ print (item )
156+
157+ if unselected_items :
158+ print ("\n Unselected (copy and paste ready):" )
159+ for item in _print_each_plain (unselected_items ):
160+ print (item )
161+
162+
163+ def _handle_all_selected (fmt : str , ruff_config : dict [str , object ]) -> None :
164+ """Handle the case when ALL rules are selected."""
165+ ignored = get_rule_selection (ruff_config , "ignore" )
166+ missed = [
167+ r
168+ for r in IGNORE_INFO
169+ if not any (
170+ x .startswith ((r .get ("rule" , "." ), r .get ("family" , "." )))
171+ for x in (ignored or [])
172+ )
173+ ]
174+
175+ msg = '[green]"ALL"[/green] selected.' if fmt == "rich" else '"ALL" selected.'
176+ if fmt == "rich" :
177+ import rich
178+
179+ rich .print (msg )
180+ else :
181+ print (msg )
182+
183+ ignores = {v .get ("rule" , v .get ("family" , "" )): v ["reason" ] for v in missed }
184+ if ignores :
185+ msg_header = "Some things that sometimes need ignoring:"
186+ if fmt == "rich" :
187+ import rich
188+
189+ rich .print (msg_header )
190+ for item in _print_each_rich (ignores ):
191+ rich .print (item )
192+ else :
193+ print (msg_header )
194+ for item in _print_each_plain (ignores ):
195+ print (item )
196+
197+
198+ def process_dir (path : Path , format : str = "auto" ) -> None :
199+ """Process a directory and display ruff rules configuration.
200+
201+ Args:
202+ path: Directory to process
203+ format: Output format - 'auto', 'rich', or 'plain'
204+ """
205+ fmt = _resolve_format (format )
206+
54207 try :
55208 with path .joinpath ("pyproject.toml" ).open ("rb" ) as f :
56209 pyproject = tomllib .load (f )
57210 except FileNotFoundError :
58211 pyproject = {}
59212
60213 ruff_config = ruff (pyproject = pyproject , root = path )
214+ if fmt == "rich" and not _has_rich ():
215+ _output_error (
216+ "plain" , "Error: --format rich requested, but rich is not installed"
217+ )
218+ raise SystemExit (3 )
219+
61220 if ruff_config is None :
62- print (
63- "[red]Could not find a ruff config [dim](.ruff.toml, ruff.toml, or pyproject.toml)" ,
64- file = sys .stderr ,
221+ msg = (
222+ "[red]Could not find a ruff config [dim](.ruff.toml, ruff.toml, or pyproject.toml)"
223+ if fmt == "rich"
224+ else "Error: Could not find a ruff config (.ruff.toml, ruff.toml, or pyproject.toml)"
65225 )
226+ _output_error (fmt , msg )
66227 raise SystemExit (1 )
228+
67229 selected = get_rule_selection (ruff_config )
68230 if not selected :
69- print (
70- "[red]No rules selected" ,
71- file = sys .stderr ,
72- )
231+ msg = "[red]No rules selected" if fmt == "rich" else "Error: No rules selected"
232+ _output_error (fmt , msg )
73233 raise SystemExit (2 )
74234
75235 if "ALL" in selected :
76- ignored = get_rule_selection (ruff_config , "ignore" )
77- missed = [
78- r
79- for r in IGNORE_INFO
80- if not any (
81- x .startswith ((r .get ("rule" , "." ), r .get ("family" , "." )))
82- for x in ignored
83- )
84- ]
85-
86- print ('[green]"ALL"[/green] selected.' )
87- ignores = {v .get ("rule" , v .get ("family" , "" )): v ["reason" ] for v in missed }
88- if ignores :
89- print ("Some things that sometimes need ignoring:" )
90- for item in print_each (ignores ):
91- print (item )
236+ _handle_all_selected (fmt , ruff_config )
92237 return
93238
94239 selected_items = {k : v for k , v in LINT_INFO .items () if k in selected }
@@ -99,23 +244,10 @@ def process_dir(path: Path) -> None:
99244 libs_items = {k : v for k , v in all_uns_items .items () if k in LIBS }
100245 spec_items = {k : v for k , v in all_uns_items .items () if k in SPECIALTY }
101246
102- panel_sel = Panel (
103- "\n " .join (print_each (selected_items )), title = "Selected" , border_style = "green"
104- )
105- panel_lib = Panel (
106- "\n " .join (print_each (libs_items )),
107- title = "Library specific" ,
108- border_style = "yellow" ,
109- )
110- panel_spec = Panel (
111- "\n " .join (print_each (spec_items )), title = "Specialized" , border_style = "yellow"
112- )
113- uns = "\n " .join (print_each (unselected_items ))
114-
115- print (Columns ([panel_sel , panel_lib , panel_spec ]))
116- if uns :
117- print ("[red]Unselected [dim](copy and paste ready)" )
118- print (uns )
247+ if fmt == "rich" :
248+ _print_output_rich (selected_items , libs_items , spec_items , unselected_items )
249+ else :
250+ _print_output_plain (selected_items , libs_items , spec_items , unselected_items )
119251
120252
121253def main () -> None :
@@ -127,9 +259,15 @@ def main() -> None:
127259 default = Path .cwd (),
128260 help = "Directory to process (default: current working directory)" ,
129261 )
262+ parser .add_argument (
263+ "--format" ,
264+ choices = ["auto" , "rich" , "plain" ],
265+ default = "auto" ,
266+ help = "Output format (default: auto)" ,
267+ )
130268 args = parser .parse_args ()
131269
132- process_dir (args .path )
270+ process_dir (args .path , format = args . format )
133271
134272
135273if __name__ == "__main__" :
0 commit comments