codegraph-engine 2.1.7__py3-none-any.whl → 2.2.0__py3-none-any.whl

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.
codegraph/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """CodeGraph MCP: local, evidence-backed codebase intelligence."""
2
2
 
3
- __version__ = "2.1.7"
3
+ __version__ = "2.2.0"
4
4
  from codegraph.errors import (
5
5
  CodeGraphError,
6
6
  ErrorCode,
codegraph/cli.py CHANGED
@@ -9,6 +9,7 @@ import typer
9
9
 
10
10
  from codegraph import __version__
11
11
  from codegraph.architecture import get_architecture
12
+ from codegraph.cli_output import cli_echo
12
13
  from codegraph.config import Settings
13
14
  from codegraph.context import get_context
14
15
  from codegraph.errors import ErrorCode, SecurityError
@@ -433,6 +434,8 @@ def routes(
433
434
  framework: Annotated[str | None, typer.Option("--framework", "-f", help="Filter by framework (e.g. flask, fastapi)")] = None,
434
435
  method: Annotated[str | None, typer.Option("--method", "-m", help="Filter by HTTP method (e.g. GET, POST)")] = None,
435
436
  route_path: Annotated[str | None, typer.Option("--path", "-p", help="Filter by route path substring")] = None,
437
+ limit: Annotated[int | None, typer.Option("--limit", "-l", help="Maximum number of routes to return")] = None,
438
+ offset: Annotated[int, typer.Option("--offset", help="Number of routes to skip")] = 0,
436
439
  json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
437
440
  ) -> None:
438
441
  """List and inspect indexed framework routes."""
@@ -478,6 +481,11 @@ def routes(
478
481
  for r in rows
479
482
  ]
480
483
 
484
+ if limit is not None:
485
+ safe_offset = max(0, offset)
486
+ safe_limit = max(0, limit)
487
+ route_list = route_list[safe_offset : safe_offset + safe_limit]
488
+
481
489
  if json_output:
482
490
  typer.echo(json.dumps(route_list, indent=2))
483
491
  else:
@@ -596,17 +604,60 @@ def debug(
596
604
  )
597
605
 
598
606
 
607
+ @app.command(
608
+ "run",
609
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
610
+ )
611
+ def run_cmd(
612
+ ctx: typer.Context,
613
+ command: Annotated[list[str] | None, typer.Argument(help="Development command and arguments to execute")] = None,
614
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
615
+ sample_rate: Annotated[float, typer.Option("--sample-rate", help="Sampling rate for trace capture (0.0..1.0)")] = 1.0,
616
+ ) -> None:
617
+ """Execute a development command or server with transparent runtime trace capture."""
618
+ full_cmd = (command or []) + ctx.args
619
+ if not full_cmd:
620
+ cli_echo("Error: Command cannot be empty. Usage: codegraph run <command> [args...]")
621
+ raise typer.Exit(code=1)
622
+
623
+ repo = _resolve_repo(None, repository)
624
+ from codegraph.runtime.runner import run_with_telemetry
625
+
626
+ exit_code = run_with_telemetry(full_cmd, repository=repo, sample_rate=sample_rate)
627
+ raise typer.Exit(code=exit_code)
628
+
629
+
630
+ def _run_server(
631
+ path: Path | None,
632
+ repository: Path | None,
633
+ profile: str,
634
+ transport: str = "stdio",
635
+ host: str = "127.0.0.1",
636
+ port: int = 8765,
637
+ ) -> None:
638
+ repo = _resolve_repo(path, repository)
639
+ from codegraph.mcp import create_server
640
+ from codegraph.process_lifecycle import run_mcp_stdio_server
641
+
642
+ if transport.lower() == "sse":
643
+ server = create_server(repo, profile=profile, host=host, port=port)
644
+ server.run(transport="sse")
645
+ else:
646
+ server = create_server(repo, profile=profile)
647
+ run_mcp_stdio_server(server, repo, profile=profile)
648
+
649
+
599
650
  @app.command()
600
651
  def serve(
601
652
  path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
602
653
  repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
603
654
  profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | minimal | developer | full")] = "full",
655
+ transport: Annotated[str, typer.Option("--transport", "-t", help="MCP transport: stdio | sse")] = "stdio",
656
+ host: Annotated[str, typer.Option("--host", help="Host for SSE server")] = "127.0.0.1",
657
+ port: Annotated[int, typer.Option("--port", "-p", help="Port for SSE server")] = 8765,
604
658
  ) -> None:
605
- """Run the stdio MCP server (requires the optional mcp extra)."""
606
- repo = _resolve_repo(path, repository)
607
- from codegraph.mcp import create_server
608
-
609
- create_server(repo, profile=profile).run()
659
+ """Run the CodeGraph MCP server via stdio or SSE transport."""
660
+ _run_server(path, repository, profile, transport=transport, host=host, port=port)
610
661
 
611
662
 
612
663
  mcp_app = typer.Typer(help="MCP server commands.")
@@ -618,12 +669,72 @@ def mcp_serve(
618
669
  path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
619
670
  repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
620
671
  profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | graph | minimal | developer | full")] = "full",
672
+ transport: Annotated[str, typer.Option("--transport", "-t", help="MCP transport: stdio | sse")] = "stdio",
673
+ host: Annotated[str, typer.Option("--host", help="Host for SSE server")] = "127.0.0.1",
674
+ port: Annotated[int, typer.Option("--port", "-p", help="Port for SSE server")] = 8765,
621
675
  ) -> None:
622
- """Run the stdio MCP server (requires the optional mcp extra)."""
623
- repo = _resolve_repo(path, repository)
624
- from codegraph.mcp import create_server
676
+ """Run the CodeGraph MCP server via stdio or SSE transport."""
677
+ _run_server(path, repository, profile, transport=transport, host=host, port=port)
625
678
 
626
- create_server(repo, profile=profile).run()
679
+
680
+ def _execute_stop(
681
+ path: Path | None,
682
+ repository: Path | None,
683
+ all_processes: bool,
684
+ timeout: float,
685
+ json_output: bool,
686
+ ) -> None:
687
+ from codegraph.process_lifecycle import stop_codegraph_processes
688
+
689
+ target_repo: Path | None = None
690
+ if not all_processes:
691
+ target_repo = _resolve_repo(path, repository)
692
+
693
+ res = stop_codegraph_processes(
694
+ repository=target_repo,
695
+ stop_all=all_processes,
696
+ timeout_sec=timeout,
697
+ )
698
+ if json_output:
699
+ cli_echo(json.dumps(res.as_dict(), indent=2), json_mode=True)
700
+ else:
701
+ cli_echo(res.format_human())
702
+
703
+
704
+ @app.command("stop")
705
+ def stop_cmd(
706
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
707
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
708
+ all_processes: Annotated[bool, typer.Option("--all", "-a", help="Stop all CodeGraph-owned MCP processes across all repositories")] = False,
709
+ timeout: Annotated[float, typer.Option("--timeout", help="Graceful shutdown timeout in seconds before force-kill")] = 3.0,
710
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
711
+ ) -> None:
712
+ """Safely stop running CodeGraph MCP background processes and release file locks."""
713
+ _execute_stop(path, repository, all_processes, timeout, json_output)
714
+
715
+
716
+ @mcp_app.command("stop")
717
+ def mcp_stop_cmd(
718
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
719
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
720
+ all_processes: Annotated[bool, typer.Option("--all", "-a", help="Stop all CodeGraph-owned MCP processes across all repositories")] = False,
721
+ timeout: Annotated[float, typer.Option("--timeout", help="Graceful shutdown timeout in seconds before force-kill")] = 3.0,
722
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
723
+ ) -> None:
724
+ """Safely stop running CodeGraph MCP background processes and release file locks."""
725
+ _execute_stop(path, repository, all_processes, timeout, json_output)
726
+
727
+
728
+ @mcp_app.command("kill")
729
+ def mcp_kill_cmd(
730
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
731
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
732
+ all_processes: Annotated[bool, typer.Option("--all", "-a", help="Stop all CodeGraph-owned MCP processes across all repositories")] = False,
733
+ timeout: Annotated[float, typer.Option("--timeout", help="Graceful shutdown timeout in seconds before force-kill")] = 1.0,
734
+ json_output: Annotated[bool, typer.Option("--json", help="Output deterministic machine-readable JSON")] = False,
735
+ ) -> None:
736
+ """Alias for `codegraph stop` to terminate CodeGraph MCP processes and release locks."""
737
+ _execute_stop(path, repository, all_processes, timeout, json_output)
627
738
 
628
739
 
629
740
  @mcp_app.command("doctor")
@@ -636,9 +747,9 @@ def mcp_doctor(
636
747
 
637
748
  report = run_mcp_doctor(profile=profile)
638
749
  if json_output:
639
- typer.echo(json.dumps(report.as_dict(), indent=2))
750
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
640
751
  else:
641
- typer.echo(report.format_human())
752
+ cli_echo(report.format_human())
642
753
  if not report.healthy:
643
754
  raise typer.Exit(code=1)
644
755
 
@@ -656,9 +767,9 @@ def mcp_config_check(
656
767
  ws = (path or Path.cwd()).resolve()
657
768
  rep = check_mcp_configuration(workspace_dir=ws, config_file=config_file, include_example=include_example)
658
769
  if json_output:
659
- typer.echo(json.dumps(rep.as_dict(), indent=2))
770
+ cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
660
771
  else:
661
- typer.echo(f"{rep.status.value}: {rep.message}")
772
+ cli_echo(f"{rep.status.value}: {rep.message}")
662
773
 
663
774
 
664
775
  @mcp_app.command("capabilities")
@@ -666,7 +777,7 @@ def mcp_capabilities() -> None:
666
777
  """Output the deterministic machine-readable CodeGraph capability manifest."""
667
778
  from codegraph.agent_capabilities import get_capability_manifest
668
779
 
669
- typer.echo(json.dumps(get_capability_manifest(), indent=2))
780
+ cli_echo(json.dumps(get_capability_manifest(), indent=2), json_mode=True)
670
781
 
671
782
 
672
783
  @mcp_app.command("rules")
@@ -676,7 +787,7 @@ def mcp_rules(
676
787
  """Render deterministic CodeGraph agent rules for the specified agent."""
677
788
  from codegraph.agent_rules import render_agent_rules
678
789
 
679
- typer.echo(render_agent_rules(agent=agent), nl=False)
790
+ cli_echo(render_agent_rules(agent=agent), nl=False)
680
791
 
681
792
 
682
793
  @app.command()
@@ -685,9 +796,20 @@ def doctor(
685
796
  repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
686
797
  resources: Annotated[bool, typer.Option("--resources")] = False,
687
798
  database: Annotated[bool, typer.Option("--database", help="Run comprehensive database integrity and schema checks")] = False,
799
+ processes: Annotated[bool, typer.Option("--processes", help="Inspect active CodeGraph processes, parent state, and stale PID files")] = False,
688
800
  json_output: Annotated[bool, typer.Option("--json")] = False,
689
801
  ) -> None:
690
- """Check repository and index readiness, database integrity, and freshness."""
802
+ """Check repository and index readiness, database integrity, process lifecycle, and freshness."""
803
+ if processes:
804
+ from codegraph.process_lifecycle import discover_codegraph_processes
805
+
806
+ discovery = discover_codegraph_processes(clean_stale=True)
807
+ if json_output:
808
+ cli_echo(json.dumps(discovery.as_dict(), indent=2), json_mode=True)
809
+ else:
810
+ cli_echo(discovery.format_human())
811
+ return
812
+
691
813
  repo = _resolve_repo(path, repository)
692
814
  indexer_inst = _indexer(repo)
693
815
  db = repo / ".codegraph.sqlite3"
@@ -1007,7 +1129,7 @@ def install(
1007
1129
 
1008
1130
  rules_txt = render_agent_rules(canon)
1009
1131
  if json_output:
1010
- typer.echo(
1132
+ cli_echo(
1011
1133
  json.dumps(
1012
1134
  {
1013
1135
  "status": "ok",
@@ -1016,21 +1138,22 @@ def install(
1016
1138
  "instructions_block": format_marker_block(rules_txt),
1017
1139
  },
1018
1140
  indent=2,
1019
- )
1141
+ ),
1142
+ json_mode=True,
1020
1143
  )
1021
1144
  else:
1022
- typer.echo(f"# MCP Configuration ({canon})\n")
1023
- typer.echo(json.dumps(snippet, indent=2))
1024
- typer.echo(f"\n# Instructions Block ({canon})\n")
1025
- typer.echo(format_marker_block(rules_txt), nl=False)
1145
+ cli_echo(f"# MCP Configuration ({canon})\n")
1146
+ cli_echo(json.dumps(snippet, indent=2), json_mode=True)
1147
+ cli_echo(f"\n# Instructions Block ({canon})\n")
1148
+ cli_echo(format_marker_block(rules_txt), nl=False)
1026
1149
  return
1027
1150
  except CodeGraphError as exc:
1028
1151
  if json_output:
1029
- typer.echo(json.dumps(exc.to_response(), indent=2))
1152
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1030
1153
  else:
1031
- typer.echo(f"Error [{exc.code}]: {exc.message}")
1154
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1032
1155
  if exc.next_action and exc.next_action.get("command"):
1033
- typer.echo(f"\nTry:\n {exc.next_action['command']}")
1156
+ cli_echo(f"\nTry:\n {exc.next_action['command']}")
1034
1157
  raise typer.Exit(code=1) from exc
1035
1158
 
1036
1159
  ws = _resolve_repo(path, repository)
@@ -1040,15 +1163,15 @@ def install(
1040
1163
 
1041
1164
  assert isinstance(preview_rep, InstallerReport)
1042
1165
  if not json_output:
1043
- typer.echo(preview_rep.format_preview_human())
1044
- typer.echo("")
1045
- typer.echo("Continue? [y/N] ", nl=False)
1166
+ cli_echo(preview_rep.format_preview_human())
1167
+ cli_echo("")
1168
+ cli_echo("Continue? [y/N] ", nl=False)
1046
1169
  try:
1047
1170
  ans = input().strip().lower()
1048
1171
  except EOFError:
1049
1172
  ans = ""
1050
1173
  if not json_output:
1051
- typer.echo("")
1174
+ cli_echo("")
1052
1175
  return ans in ("y", "yes")
1053
1176
 
1054
1177
  try:
@@ -1062,22 +1185,22 @@ def install(
1062
1185
  )
1063
1186
  except CodeGraphError as exc:
1064
1187
  if json_output:
1065
- typer.echo(json.dumps(exc.to_response(), indent=2))
1188
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1066
1189
  else:
1067
- typer.echo(f"Error [{exc.code}]: {exc.message}")
1190
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1068
1191
  if exc.next_action and exc.next_action.get("reason"):
1069
- typer.echo(f"\nExpected:\n {exc.next_action['reason']}")
1192
+ cli_echo(f"\nExpected:\n {exc.next_action['reason']}")
1070
1193
  if exc.next_action and exc.next_action.get("command"):
1071
- typer.echo(f"\nTry:\n {exc.next_action['command']}")
1194
+ cli_echo(f"\nTry:\n {exc.next_action['command']}")
1072
1195
  raise typer.Exit(code=1) from exc
1073
1196
 
1074
1197
  if json_output:
1075
- typer.echo(json.dumps(report.as_dict(), indent=2))
1198
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
1076
1199
  else:
1077
1200
  if yes and not dry_run:
1078
- typer.echo(report.format_preview_human())
1079
- typer.echo("")
1080
- typer.echo(report.format_human())
1201
+ cli_echo(report.format_preview_human())
1202
+ cli_echo("")
1203
+ cli_echo(report.format_human())
1081
1204
 
1082
1205
  if report.status == "error":
1083
1206
  raise typer.Exit(code=1)
@@ -1107,17 +1230,17 @@ def uninstall(
1107
1230
 
1108
1231
  def _confirm_uninstall(preview_rep: InstallerReport) -> bool:
1109
1232
  if not json_output:
1110
- typer.echo("CodeGraph Uninstaller\n")
1233
+ cli_echo("CodeGraph Uninstaller\n")
1111
1234
  for c in preview_rep.changes:
1112
1235
  if c.action in ("remove", "modify"):
1113
- typer.echo(f" - {c.display_path} ({c.action})")
1114
- typer.echo("\nContinue? [y/N] ", nl=False)
1236
+ cli_echo(f" - {c.display_path} ({c.action})")
1237
+ cli_echo("\nContinue? [y/N] ", nl=False)
1115
1238
  try:
1116
1239
  ans = input().strip().lower()
1117
1240
  except EOFError:
1118
1241
  ans = ""
1119
1242
  if not json_output:
1120
- typer.echo("")
1243
+ cli_echo("")
1121
1244
  return ans in ("y", "yes")
1122
1245
 
1123
1246
  try:
@@ -1130,17 +1253,17 @@ def uninstall(
1130
1253
  )
1131
1254
  except CodeGraphError as exc:
1132
1255
  if json_output:
1133
- typer.echo(json.dumps(exc.to_response(), indent=2))
1256
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1134
1257
  else:
1135
- typer.echo(f"Error [{exc.code}]: {exc.message}")
1258
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1136
1259
  if exc.next_action and exc.next_action.get("command"):
1137
- typer.echo(f"\nTry:\n {exc.next_action['command']}")
1260
+ cli_echo(f"\nTry:\n {exc.next_action['command']}")
1138
1261
  raise typer.Exit(code=1) from exc
1139
1262
 
1140
1263
  if json_output:
1141
- typer.echo(json.dumps(report.as_dict(), indent=2))
1264
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
1142
1265
  else:
1143
- typer.echo(report.format_human())
1266
+ cli_echo(report.format_human())
1144
1267
 
1145
1268
 
1146
1269
  @app.command()
@@ -1159,16 +1282,16 @@ def uninit(
1159
1282
 
1160
1283
  def _confirm_uninit(preview_rep: InstallerReport) -> bool:
1161
1284
  if not json_output:
1162
- typer.echo("Remove CodeGraph project index files?\n")
1285
+ cli_echo("Remove CodeGraph project index files?\n")
1163
1286
  for c in preview_rep.changes:
1164
- typer.echo(f" - {c.display_path}")
1165
- typer.echo("\nContinue? [y/N] ", nl=False)
1287
+ cli_echo(f" - {c.display_path}")
1288
+ cli_echo("\nContinue? [y/N] ", nl=False)
1166
1289
  try:
1167
1290
  ans = input().strip().lower()
1168
1291
  except EOFError:
1169
1292
  ans = ""
1170
1293
  if not json_output:
1171
- typer.echo("")
1294
+ cli_echo("")
1172
1295
  return ans in ("y", "yes")
1173
1296
 
1174
1297
  try:
@@ -1179,15 +1302,15 @@ def uninit(
1179
1302
  )
1180
1303
  except CodeGraphError as exc:
1181
1304
  if json_output:
1182
- typer.echo(json.dumps(exc.to_response(), indent=2))
1305
+ cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
1183
1306
  else:
1184
- typer.echo(f"Error [{exc.code}]: {exc.message}")
1307
+ cli_echo(f"Error [{exc.code}]: {exc.message}")
1185
1308
  raise typer.Exit(code=1) from exc
1186
1309
 
1187
1310
  if json_output:
1188
- typer.echo(json.dumps(report.as_dict(), indent=2))
1311
+ cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
1189
1312
  else:
1190
- typer.echo(report.format_human())
1313
+ cli_echo(report.format_human())
1191
1314
 
1192
1315
 
1193
1316
  if __name__ == "__main__":
@@ -0,0 +1,190 @@
1
+ """Centralized cross-platform CLI output utility for Windows/macOS/Linux encoding safety.
2
+
3
+ Guarantees:
4
+ - Never assumes stdout/stderr is UTF-8 (safe on Windows cp1252, cp437, ASCII, and redirected pipes).
5
+ - Preserves rich Unicode symbols (`✓`, `✗`, `⚠`, `↻`, `(•)`) when the target stream supports them.
6
+ - Automatically degrades to deterministic ASCII markers (`[OK]`, `[ERROR]`, `[WARN]`, `[REPAIRED]`, `(*)`)
7
+ when the stream encoding does not support Unicode box/status glyphs.
8
+ - Ensures `--json` output is strictly machine-readable and never contains decorative Unicode glyphs.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import codecs
13
+ import os
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from typing import Any, TextIO
17
+
18
+ import typer
19
+
20
+ _PROBE_GLYPHS = "✓✗⚠↻•→↓─—–"
21
+
22
+ # Ordered replacements so multi-char patterns (e.g. "(•)") are replaced before single chars ("•")
23
+ _UNICODE_TO_ASCII_MAP: tuple[tuple[str, str], ...] = (
24
+ ("(•)", "(*)"),
25
+ ("✓", "[OK]"),
26
+ ("✗", "[ERROR]"),
27
+ ("⚠", "[WARN]"),
28
+ ("↻", "[REPAIRED]"),
29
+ ("•", "*"),
30
+ ("→", "->"),
31
+ ("↓", "v"),
32
+ ("—", "--"),
33
+ ("–", "-"),
34
+ ("─", "-"),
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class CliSymbols:
40
+ """Encoding-aware CLI status symbols."""
41
+
42
+ unicode_enabled: bool
43
+ ok: str
44
+ error: str
45
+ warn: str
46
+ repair: str
47
+ radio_on: str
48
+ radio_off: str
49
+ arrow_right: str
50
+ arrow_down: str
51
+
52
+
53
+ UNICODE_SYMBOLS = CliSymbols(
54
+ unicode_enabled=True,
55
+ ok="✓",
56
+ error="✗",
57
+ warn="⚠",
58
+ repair="↻",
59
+ radio_on="(•)",
60
+ radio_off="( )",
61
+ arrow_right="→",
62
+ arrow_down="↓",
63
+ )
64
+
65
+ ASCII_SYMBOLS = CliSymbols(
66
+ unicode_enabled=False,
67
+ ok="[OK]",
68
+ error="[ERROR]",
69
+ warn="[WARN]",
70
+ repair="[REPAIRED]",
71
+ radio_on="(*)",
72
+ radio_off="( )",
73
+ arrow_right="->",
74
+ arrow_down="v",
75
+ )
76
+
77
+
78
+ def detect_stream_encoding(stream: Any = None) -> str:
79
+ """Safely detect the character encoding of `stream` (defaults to `sys.stdout`)."""
80
+ target = stream if stream is not None else sys.stdout
81
+ enc = getattr(target, "encoding", None)
82
+ if not enc or not isinstance(enc, str):
83
+ return "ascii"
84
+ try:
85
+ info = codecs.lookup(enc)
86
+ return info.name
87
+ except LookupError:
88
+ return "ascii"
89
+
90
+
91
+ def supports_unicode(
92
+ stream: Any = None,
93
+ *,
94
+ unicode_override: bool | None = None,
95
+ ) -> bool:
96
+ """Return True if `stream` can encode all CodeGraph Unicode status glyphs without error."""
97
+ if unicode_override is not None:
98
+ return unicode_override
99
+
100
+ env_ascii = os.environ.get("CODEGRAPH_ASCII_OUTPUT", "").strip().lower()
101
+ if env_ascii in ("1", "true", "yes", "on"):
102
+ return False
103
+
104
+ env_unicode = os.environ.get("CODEGRAPH_UNICODE_OUTPUT", "").strip().lower()
105
+ if env_unicode in ("1", "true", "yes", "on"):
106
+ return True
107
+ if env_unicode in ("0", "false", "no", "off"):
108
+ return False
109
+
110
+ enc = detect_stream_encoding(stream)
111
+ try:
112
+ _PROBE_GLYPHS.encode(enc, errors="strict")
113
+ return True
114
+ except (UnicodeEncodeError, LookupError):
115
+ return False
116
+
117
+
118
+ def get_cli_symbols(
119
+ stream: Any = None,
120
+ *,
121
+ unicode_override: bool | None = None,
122
+ ) -> CliSymbols:
123
+ """Return `UNICODE_SYMBOLS` when supported by `stream`, else `ASCII_SYMBOLS`."""
124
+ if supports_unicode(stream, unicode_override=unicode_override):
125
+ return UNICODE_SYMBOLS
126
+ return ASCII_SYMBOLS
127
+
128
+
129
+ def sanitize_text_for_stream(
130
+ text: str,
131
+ stream: Any = None,
132
+ *,
133
+ unicode_override: bool | None = None,
134
+ ) -> str:
135
+ """Format `text` safely for `stream`, converting decorative Unicode when unsupported."""
136
+ if not text:
137
+ return text
138
+
139
+ target = stream if stream is not None else sys.stdout
140
+ use_unicode = supports_unicode(target, unicode_override=unicode_override)
141
+ out = text
142
+ if not use_unicode:
143
+ for uni_tok, ascii_tok in _UNICODE_TO_ASCII_MAP:
144
+ if uni_tok in out:
145
+ out = out.replace(uni_tok, ascii_tok)
146
+
147
+ enc = detect_stream_encoding(target)
148
+ try:
149
+ out.encode(enc, errors="strict")
150
+ return out
151
+ except (UnicodeEncodeError, LookupError):
152
+ # Replace any remaining unencodable glyphs cleanly so write() never raises UnicodeEncodeError
153
+ for uni_tok, ascii_tok in _UNICODE_TO_ASCII_MAP:
154
+ if uni_tok in out:
155
+ out = out.replace(uni_tok, ascii_tok)
156
+ return out.encode(enc, errors="replace").decode(enc, errors="replace")
157
+
158
+
159
+ def sanitize_json_string(raw_json: str, stream: Any = None) -> str:
160
+ """Ensure machine-readable JSON output never crashes non-UTF-8 streams."""
161
+ enc = detect_stream_encoding(stream)
162
+ try:
163
+ raw_json.encode(enc, errors="strict")
164
+ return raw_json
165
+ except (UnicodeEncodeError, LookupError):
166
+ # Escape non-ASCII characters using standard JSON \uXXXX escapes
167
+ return raw_json.encode("unicode_escape").decode("ascii")
168
+
169
+
170
+ def cli_echo(
171
+ message: str = "",
172
+ *,
173
+ nl: bool = True,
174
+ err: bool = False,
175
+ json_mode: bool = False,
176
+ stream: TextIO | None = None,
177
+ unicode_override: bool | None = None,
178
+ ) -> None:
179
+ """Centralized safe CLI output function replacing raw `typer.echo`."""
180
+ target = stream if stream is not None else (sys.stderr if err else sys.stdout)
181
+ if json_mode:
182
+ safe_msg = sanitize_json_string(str(message), target)
183
+ else:
184
+ safe_msg = sanitize_text_for_stream(str(message), target, unicode_override=unicode_override)
185
+
186
+ if stream is not None:
187
+ stream.write(safe_msg + ("\n" if nl else ""))
188
+ stream.flush()
189
+ else:
190
+ typer.echo(safe_msg, nl=nl, err=err)