robotcode-plugin 2.5.0__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -331,7 +331,7 @@ output.xml
331
331
  bundled/libs
332
332
 
333
333
  # robotframework
334
- results/
334
+ /results/
335
335
 
336
336
  # kilocode
337
337
  .kilocode/
@@ -339,3 +339,7 @@ results/
339
339
  # .agents
340
340
  .agents/
341
341
  skills-lock.json
342
+ .claude
343
+
344
+ # sarif files
345
+ /**/*.sarif.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotcode-plugin
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Some classes for RobotCode plugin management
5
5
  Project-URL: Donate, https://opencollective.com/robotcode
6
6
  Project-URL: Documentation, https://github.com/robotcodedev/robotcode#readme
@@ -27,6 +27,7 @@ Requires-Python: >=3.10
27
27
  Requires-Dist: click>=8.2.0
28
28
  Requires-Dist: colorama>=0.4.6
29
29
  Requires-Dist: pluggy>=1.0.0
30
+ Requires-Dist: rich>=14.0.0
30
31
  Requires-Dist: tomli-w>=1.0.0
31
32
  Description-Content-Type: text/markdown
32
33
 
@@ -31,6 +31,7 @@ dependencies = [
31
31
  "pluggy>=1.0.0",
32
32
  "tomli_w>=1.0.0",
33
33
  "colorama>=0.4.6",
34
+ "rich>=14.0.0",
34
35
  ]
35
36
  dynamic = ["version"]
36
37
 
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from types import TracebackType
9
9
  from typing import (
10
10
  IO,
11
+ TYPE_CHECKING,
11
12
  Any,
12
13
  AnyStr,
13
14
  Callable,
@@ -29,6 +30,9 @@ import tomli_w
29
30
  from robotcode.core.utils.dataclasses import as_dict, as_json
30
31
  from robotcode.core.utils.path import same_file
31
32
 
33
+ if TYPE_CHECKING:
34
+ from rich.markdown import Markdown
35
+
32
36
  __all__ = [
33
37
  "Application",
34
38
  "ColoredOutput",
@@ -101,6 +105,101 @@ class ProgressBar(Protocol[T]):
101
105
  def __next__(self) -> T: ...
102
106
 
103
107
 
108
+ _deep_markdown_cls: "Optional[type[Markdown]]" = None
109
+
110
+
111
+ def _get_deep_markdown_cls() -> "type[Markdown]":
112
+ """Return a cached `rich.markdown.Markdown` subclass tuned for our
113
+ output, building it on first use.
114
+
115
+ `rich` (and its `markdown-it-py` dependency) are hard requirements
116
+ now, so there's no ImportError fallback — but the import is still
117
+ deferred to first use so the `--version` / `--help` fast paths don't
118
+ pay for it.
119
+
120
+ All customisation lives on the subclass's own `elements` mapping
121
+ and `__init__`, so we never mutate rich's global `Markdown.elements`
122
+ ClassVar or the shared element classes — any other `rich.Markdown`
123
+ user in the process keeps stock behaviour. Built once and cached."""
124
+ global _deep_markdown_cls
125
+ if _deep_markdown_cls is not None:
126
+ return _deep_markdown_cls
127
+
128
+ from markdown_it import MarkdownIt
129
+ from rich.console import Console, ConsoleOptions, RenderResult
130
+ from rich.markdown import (
131
+ Heading,
132
+ Markdown,
133
+ TableBodyElement,
134
+ TableDataElement,
135
+ TableElement,
136
+ TableHeaderElement,
137
+ TableRowElement,
138
+ )
139
+ from rich.text import Text
140
+
141
+ class LeftHeading(Heading):
142
+ """Left-justify headings instead of rich's default centering."""
143
+
144
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
145
+ for result in super().__rich_console__(console, options):
146
+ cast(Text, result).justify = "left"
147
+ yield result
148
+
149
+ # rich#3027: table rows render with spurious blank lines unless the
150
+ # table element classes carry `new_line = False`. Subclass rather
151
+ # than patch the originals so the change is scoped to our renderer.
152
+ class _Table(TableElement):
153
+ new_line = False
154
+
155
+ class _TableHeader(TableHeaderElement):
156
+ new_line = False
157
+
158
+ class _TableBody(TableBodyElement):
159
+ new_line = False
160
+
161
+ class _TableRow(TableRowElement):
162
+ new_line = False
163
+
164
+ class _TableData(TableDataElement):
165
+ new_line = False
166
+
167
+ class DeepMarkdown(Markdown):
168
+ """`rich.markdown.Markdown` builds a `MarkdownIt()` whose
169
+ `maxNesting` defaults to 20 — and every nested ``list +
170
+ listitem`` eats two of that budget. A workspace-scale
171
+ `discover all` document with the typical Robot directory layout
172
+ (`tests/foo/bar/baz/…`) trips the limit around the 8th nested
173
+ level, and markdown-it-py silently discards the rest of the
174
+ document — including any footer such as the Statistics block.
175
+ Re-parse with a much higher limit so arbitrarily deep trees
176
+ render in full.
177
+
178
+ Customised token → element mappings (left-justified headings,
179
+ blank-line-free tables) are overridden on this subclass's own
180
+ `elements` map, leaving rich's global ClassVar untouched."""
181
+
182
+ elements = {
183
+ **Markdown.elements,
184
+ "heading_open": LeftHeading,
185
+ "table_open": _Table,
186
+ "thead_open": _TableHeader,
187
+ "tbody_open": _TableBody,
188
+ "tr_open": _TableRow,
189
+ "td_open": _TableData,
190
+ "th_open": _TableData,
191
+ }
192
+
193
+ def __init__(self, markup: str, **kwargs: Any) -> None:
194
+ super().__init__(markup, **kwargs)
195
+ parser = MarkdownIt().enable("strikethrough").enable("table")
196
+ parser.options.maxNesting = 1000
197
+ self.parsed = parser.parse(markup)
198
+
199
+ _deep_markdown_cls = DeepMarkdown
200
+ return DeepMarkdown
201
+
202
+
104
203
  class Application:
105
204
  def __init__(self) -> None:
106
205
  self.config = CommonConfig()
@@ -115,12 +214,35 @@ class Application:
115
214
  def show_diagnostics(self, value: bool) -> None:
116
215
  self._show_diagnostics = value
117
216
 
217
+ def color_for(self, file: Optional[IO[Any]] = None, err: bool = False) -> bool:
218
+ """Resolve the color decision for a specific output destination.
219
+
220
+ Honors the explicit ``--color`` / ``--no-color`` choice first, then the
221
+ ``FORCE_COLOR`` / ``NO_COLOR`` conventions (https://no-color.org/), and
222
+ finally the TTY status of the relevant stream — which may be stdout,
223
+ stderr, or an explicit ``file=``. This matters because stdout and
224
+ stderr can be redirected independently: piping ``stdout`` into a file
225
+ should not disable colour on warnings written to ``stderr`` if that
226
+ is still attached to a terminal.
227
+ """
228
+ pref = self.config.colored_output
229
+ if pref == ColoredOutput.NO:
230
+ return False
231
+ if pref == ColoredOutput.YES:
232
+ return True
233
+ if os.environ.get("FORCE_COLOR"):
234
+ return True
235
+ if os.environ.get("NO_COLOR"):
236
+ return False
237
+ stream = file if file is not None else (sys.stderr if err else sys.stdout)
238
+ isatty = getattr(stream, "isatty", None)
239
+ return bool(isatty()) if callable(isatty) else False
240
+
118
241
  @property
119
242
  def colored(self) -> bool:
120
- return self.config.colored_output in [
121
- ColoredOutput.AUTO,
122
- ColoredOutput.YES,
123
- ]
243
+ """Shortcut for the color decision on stdout — used for branching
244
+ between rich and plain rendering paths that always target stdout."""
245
+ return self.color_for()
124
246
 
125
247
  @property
126
248
  def has_rich(self) -> bool:
@@ -139,12 +261,13 @@ class Application:
139
261
  err: Optional[bool] = True,
140
262
  ) -> None:
141
263
  if self.config.verbose:
264
+ err_resolved = err if err is not None else True
142
265
  click.secho(
143
266
  message() if callable(message) else message,
144
267
  file=file,
145
268
  nl=nl if nl is not None else True,
146
- err=err if err is not None else True,
147
- color=self.colored,
269
+ err=err_resolved,
270
+ color=self.color_for(file=file, err=err_resolved),
148
271
  fg="bright_black",
149
272
  )
150
273
 
@@ -167,7 +290,7 @@ class Application:
167
290
  show_percent=show_percent,
168
291
  show_pos=show_pos,
169
292
  file=sys.stderr,
170
- color=self.colored,
293
+ color=self.color_for(file=sys.stderr),
171
294
  )
172
295
 
173
296
  def warning(
@@ -177,12 +300,13 @@ class Application:
177
300
  nl: Optional[bool] = True,
178
301
  err: Optional[bool] = True,
179
302
  ) -> None:
303
+ err_resolved = err if err is not None else True
180
304
  click.secho(
181
305
  f"[ {click.style('WARN', fg='yellow')} ] {message() if callable(message) else message}",
182
306
  file=file,
183
307
  nl=nl if nl is not None else True,
184
- err=err if err is not None else True,
185
- color=self.colored,
308
+ err=err_resolved,
309
+ color=self.color_for(file=file, err=err_resolved),
186
310
  fg="bright_yellow",
187
311
  )
188
312
 
@@ -193,12 +317,13 @@ class Application:
193
317
  nl: Optional[bool] = True,
194
318
  err: Optional[bool] = True,
195
319
  ) -> None:
320
+ err_resolved = err if err is not None else True
196
321
  click.secho(
197
322
  f"[ {click.style('ERROR', fg='red')} ] {message() if callable(message) else message}",
198
323
  file=file,
199
324
  nl=nl if nl is not None else True,
200
- err=err if err is not None else True,
201
- color=self.colored,
325
+ err=err_resolved,
326
+ color=self.color_for(file=file, err=err_resolved),
202
327
  )
203
328
 
204
329
  def print_data(
@@ -240,21 +365,19 @@ class Application:
240
365
  if format == OutputFormat.JSON_INDENT:
241
366
  format = OutputFormat.JSON
242
367
  console = Console(soft_wrap=True)
243
- if self.config.pager:
368
+ syntax = Syntax(text, format, background_color="default")
369
+ if self._should_page(lambda: text.count("\n")):
244
370
  with console.pager(styles=True, links=True):
245
- console.print(Syntax(text, format, background_color="default"))
371
+ console.print(syntax)
246
372
  else:
247
- console.print(Syntax(text, format, background_color="default"))
373
+ console.print(syntax)
248
374
 
249
375
  return
250
376
  except ImportError:
251
377
  if self.config.colored_output == ColoredOutput.YES:
252
378
  self.warning('Package "rich" is required to use colored output.')
253
379
 
254
- if self.config.pager:
255
- self.echo_via_pager(text)
256
- else:
257
- self.echo(text)
380
+ self.echo_via_pager(text)
258
381
 
259
382
  return
260
383
 
@@ -269,55 +392,57 @@ class Application:
269
392
  message() if callable(message) else message,
270
393
  file=file,
271
394
  nl=nl,
272
- color=self.colored,
395
+ color=self.color_for(file=file, err=err),
273
396
  err=err,
274
397
  )
275
398
 
276
- def echo_as_markdown(self, text: str) -> None:
277
- if self.colored:
278
- try:
279
- from rich.console import Console, ConsoleOptions, RenderResult
280
- from rich.markdown import (
281
- Heading,
282
- Markdown,
283
- TableBodyElement,
284
- TableDataElement,
285
- TableElement,
286
- TableHeaderElement,
287
- TableRowElement,
288
- )
289
- from rich.text import Text
290
-
291
- # this is needed because of https://github.com/Textualize/rich/issues/3027
292
- TableElement.new_line = False
293
- TableHeaderElement.new_line = False
294
- TableBodyElement.new_line = False
295
- TableRowElement.new_line = False
296
- TableDataElement.new_line = False
399
+ def _should_page(self, measure_lines: Callable[[], int]) -> bool:
400
+ """Tri-state pager decision.
401
+
402
+ - `config.pager` is True → always page.
403
+ - `config.pager` is False → never page.
404
+ - `config.pager` is None → auto: page only when stdout is a TTY AND
405
+ the rendered output exceeds the terminal
406
+ height (measured via the callback).
407
+ """
408
+ pref = self.config.pager
409
+ if pref is True:
410
+ return True
411
+ if pref is False:
412
+ return False
413
+ if not sys.stdout.isatty():
414
+ return False
415
+ try:
416
+ from shutil import get_terminal_size
297
417
 
298
- class MyHeading(Heading):
299
- def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
300
- for result in super().__rich_console__(console, options):
301
- cast(Text, result).justify = "left"
418
+ term_lines = get_terminal_size(fallback=(80, 24)).lines
419
+ return measure_lines() > term_lines - 2
420
+ except Exception:
421
+ return False
302
422
 
303
- yield result
423
+ def echo_as_markdown(self, text: str) -> None:
424
+ # Plain / piped output just emits the raw markdown — it's
425
+ # readable as-is and pastable into PRs, Slack, or an LLM.
426
+ if not self.colored:
427
+ self.echo_via_pager(text)
428
+ return
304
429
 
305
- Markdown.elements["heading_open"] = MyHeading
430
+ from rich.console import Console
306
431
 
307
- markdown = Markdown(text, justify="left", code_theme="default")
432
+ markdown = _get_deep_markdown_cls()(text, justify="left", code_theme="default")
433
+ console = Console()
308
434
 
309
- console = Console()
310
- if self.config.pager:
311
- with console.pager(styles=True, links=True):
312
- console.print(markdown)
313
- else:
314
- console.print(markdown)
315
- return
316
- except ImportError:
317
- if self.config.colored_output == ColoredOutput.YES:
318
- self.warning('Package "rich" is required to use colored output.')
435
+ def _measure() -> int:
436
+ measure = Console(width=console.size.width, record=False, soft_wrap=True)
437
+ with measure.capture() as cap:
438
+ measure.print(markdown)
439
+ return cap.get().count("\n")
319
440
 
320
- self.echo_via_pager(text)
441
+ if self._should_page(_measure):
442
+ with console.pager(styles=True, links=True):
443
+ console.print(markdown)
444
+ else:
445
+ console.print(markdown)
321
446
 
322
447
  def echo_via_pager(
323
448
  self,
@@ -325,18 +450,20 @@ class Application:
325
450
  color: Optional[bool] = None,
326
451
  ) -> None:
327
452
  try:
328
- if not self.config.pager:
329
- text = (
330
- text_or_generator
331
- if isinstance(text_or_generator, str)
332
- else "".join(text_or_generator() if callable(text_or_generator) else text_or_generator)
333
- )
334
- click.echo(text, color=color if color is not None else self.colored)
453
+ text = (
454
+ text_or_generator
455
+ if isinstance(text_or_generator, str)
456
+ else "".join(text_or_generator() if callable(text_or_generator) else text_or_generator)
457
+ )
458
+ use_color = color if color is not None else self.colored
459
+ if self._should_page(lambda: text.count("\n")):
460
+ if use_color:
461
+ # click only sets `LESS=-R` when color=None — set it here so
462
+ # less renders ANSI styles instead of showing raw ESC codes.
463
+ os.environ.setdefault("LESS", "-R")
464
+ click.echo_via_pager(text, color=use_color)
335
465
  else:
336
- click.echo_via_pager(
337
- text_or_generator,
338
- color=color if color is not None else self.colored,
339
- )
466
+ click.echo(text, color=use_color)
340
467
  except OSError:
341
468
  pass
342
469
 
@@ -0,0 +1 @@
1
+ __version__ = "2.6.0"
@@ -0,0 +1,102 @@
1
+ """Detect whether the current process runs inside an AI agent session.
2
+
3
+ There is no agreed-upon standard yet — we check a union of variables
4
+ known to be set by popular tools (Claude Code, Cursor, Copilot CLI,
5
+ OpenCode, Codex, …), plus the proposed generic conventions ``AI_AGENT``
6
+ and ``AGENT``. When any of them is set to a truthy value, callers use
7
+ the result to flip presentation defaults (colour, pager, REPL plain
8
+ backend) so agents get clean stdin/stdout without users having to pass
9
+ ``--no-color`` / ``--no-pager`` / ``--plain`` on every invocation.
10
+
11
+ Override hatches:
12
+
13
+ - ``ROBOTCODE_FORCE_AI_AGENT=1`` — force the detection on (for testing
14
+ and for cases where a marker variable isn't recognised yet).
15
+ - ``ROBOTCODE_NO_AI_AGENT=1`` — force the detection off, regardless of
16
+ any other marker.
17
+ """
18
+
19
+ import os
20
+ from typing import Final, Tuple
21
+
22
+ _AGENT_ENV_VARS: Final[Tuple[str, ...]] = (
23
+ # Generic / proposed standards
24
+ "AI_AGENT",
25
+ "AGENT",
26
+ # Anthropic
27
+ "CLAUDECODE",
28
+ "CLAUDE_CODE",
29
+ # Cursor
30
+ "CURSOR_AGENT",
31
+ "CURSOR_TRACE_ID",
32
+ # OpenAI Codex (set on every subprocess Codex spawns)
33
+ "CODEX_CI",
34
+ "CODEX_THREAD_ID",
35
+ "CODEX_SANDBOX", # macOS-Seatbelt only; treated as a secondary signal
36
+ # Google
37
+ "GEMINI_CLI",
38
+ "ANTIGRAVITY_AGENT",
39
+ # GitHub Copilot — COPILOT_AGENT is set in terminals launched from
40
+ # VS Code Copilot Chat / agent mode (see microsoft/vscode#311734);
41
+ # COPILOT_AGENT_SESSION_ID is set on every shell command and MCP
42
+ # server the standalone Copilot CLI spawns (>= 0.0.429, April 2026).
43
+ "COPILOT_AGENT",
44
+ "COPILOT_AGENT_SESSION_ID",
45
+ # Microsoft VS Code (1.121+): set when a terminal command is launched
46
+ # by the VS Code agent flow (Copilot Chat) rather than a human.
47
+ "VSCODE_AGENT",
48
+ # opencode (sets both OPENCODE and AGENT)
49
+ "OPENCODE",
50
+ "OPENCODE_CLIENT", # legacy / Vercel-detection compat
51
+ # Misc agents
52
+ "AUGMENT_AGENT",
53
+ "CLINE_ACTIVE",
54
+ )
55
+
56
+ _FORCE_ON_VAR: Final = "ROBOTCODE_FORCE_AI_AGENT"
57
+ _FORCE_OFF_VAR: Final = "ROBOTCODE_NO_AI_AGENT"
58
+
59
+
60
+ def _is_active(var: str) -> bool:
61
+ """A marker var is "active" when it is present in the environment
62
+ with any value other than the empty string or ``"0"``.
63
+
64
+ Different agents put very different things in their marker (``1``,
65
+ the agent's name, a session id, …), so anything we'd treat as
66
+ "no, you weren't serious" risks fighting the agent. Only the
67
+ explicit ``=0`` opt-out and absence count as off.
68
+ """
69
+ value = os.environ.get(var)
70
+ if value is None:
71
+ return False
72
+ return value.strip() not in ("", "0")
73
+
74
+
75
+ def is_running_in_ai_agent() -> bool:
76
+ """True when any known agent-marker env var is active.
77
+
78
+ `ROBOTCODE_FORCE_AI_AGENT` wins over everything; `ROBOTCODE_NO_AI_AGENT`
79
+ wins over the tool-specific markers but loses to `ROBOTCODE_FORCE_AI_AGENT`.
80
+ """
81
+ if _is_active(_FORCE_ON_VAR):
82
+ return True
83
+ if _is_active(_FORCE_OFF_VAR):
84
+ return False
85
+ return any(_is_active(var) for var in _AGENT_ENV_VARS)
86
+
87
+
88
+ def detected_agent_marker() -> str:
89
+ """Name of the first active env var, or ``""`` when none is set.
90
+
91
+ Returns the override-var name when one of them dictated the result;
92
+ otherwise the first tool-specific marker found, in the order listed
93
+ in `_AGENT_ENV_VARS`. For diagnostics / debug logging only.
94
+ """
95
+ if _is_active(_FORCE_ON_VAR):
96
+ return _FORCE_ON_VAR
97
+ if _is_active(_FORCE_OFF_VAR):
98
+ return ""
99
+ for var in _AGENT_ENV_VARS:
100
+ if _is_active(var):
101
+ return var
102
+ return ""
@@ -30,7 +30,7 @@ class EnumChoice(click.Choice, Generic[T]): # type: ignore[type-arg]
30
30
  excluded: Optional[Set[T]] = None,
31
31
  ) -> None:
32
32
  super().__init__(
33
- choices if excluded is None else (set(choices).difference(excluded)),
33
+ choices if excluded is None else [c for c in choices if c not in excluded],
34
34
  case_sensitive,
35
35
  )
36
36
 
@@ -76,7 +76,7 @@ class AddressesPort(NamedTuple):
76
76
  port: Optional[int] = None
77
77
 
78
78
 
79
- class AddressPortParamType(click.ParamType):
79
+ class AddressPortParamType(click.ParamType): # type: ignore[type-arg]
80
80
  name = "[<address>:]<port>"
81
81
 
82
82
  def convert(
@@ -113,7 +113,7 @@ class MutuallyExclusiveOption(click.Option):
113
113
  self.mutually_exclusive = mutually_exclusive
114
114
  help = kwargs.get("help", "")
115
115
  if self.mutually_exclusive:
116
- ex_str = ", ".join(self.mutually_exclusive)
116
+ ex_str = ", ".join(sorted(self.mutually_exclusive))
117
117
  kwargs["help"] = help + ("\n*NOTE:* This option is mutually exclusive with options: " + ex_str + ".")
118
118
  super(MutuallyExclusiveOption, self).__init__(*args, **kwargs)
119
119
 
@@ -1 +0,0 @@
1
- __version__ = "2.5.0"