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 +1 -1
- codegraph/cli.py +177 -54
- codegraph/cli_output.py +190 -0
- codegraph/context.py +119 -3
- codegraph/database/interrogation.py +30 -7
- codegraph/freshness.py +59 -21
- codegraph/indexing/indexer.py +11 -4
- codegraph/installer.py +72 -22
- codegraph/interrogation.py +19 -0
- codegraph/mcp/server.py +97 -6
- codegraph/mcp_diagnostics.py +5 -2
- codegraph/monorepo.py +1 -1
- codegraph/process_lifecycle.py +1115 -0
- codegraph/runtime/runner.py +204 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/METADATA +48 -23
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/RECORD +20 -17
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.1.7.dist-info → codegraph_engine-2.2.0.dist-info}/top_level.txt +0 -0
codegraph/__init__.py
CHANGED
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
|
|
606
|
-
|
|
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
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
+
cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
|
|
640
751
|
else:
|
|
641
|
-
|
|
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
|
-
|
|
770
|
+
cli_echo(json.dumps(rep.as_dict(), indent=2), json_mode=True)
|
|
660
771
|
else:
|
|
661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1152
|
+
cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
|
|
1030
1153
|
else:
|
|
1031
|
-
|
|
1154
|
+
cli_echo(f"Error [{exc.code}]: {exc.message}")
|
|
1032
1155
|
if exc.next_action and exc.next_action.get("command"):
|
|
1033
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1188
|
+
cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
|
|
1066
1189
|
else:
|
|
1067
|
-
|
|
1190
|
+
cli_echo(f"Error [{exc.code}]: {exc.message}")
|
|
1068
1191
|
if exc.next_action and exc.next_action.get("reason"):
|
|
1069
|
-
|
|
1192
|
+
cli_echo(f"\nExpected:\n {exc.next_action['reason']}")
|
|
1070
1193
|
if exc.next_action and exc.next_action.get("command"):
|
|
1071
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1233
|
+
cli_echo("CodeGraph Uninstaller\n")
|
|
1111
1234
|
for c in preview_rep.changes:
|
|
1112
1235
|
if c.action in ("remove", "modify"):
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1256
|
+
cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
|
|
1134
1257
|
else:
|
|
1135
|
-
|
|
1258
|
+
cli_echo(f"Error [{exc.code}]: {exc.message}")
|
|
1136
1259
|
if exc.next_action and exc.next_action.get("command"):
|
|
1137
|
-
|
|
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
|
-
|
|
1264
|
+
cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
|
|
1142
1265
|
else:
|
|
1143
|
-
|
|
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
|
-
|
|
1285
|
+
cli_echo("Remove CodeGraph project index files?\n")
|
|
1163
1286
|
for c in preview_rep.changes:
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1305
|
+
cli_echo(json.dumps(exc.to_response(), indent=2), json_mode=True)
|
|
1183
1306
|
else:
|
|
1184
|
-
|
|
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
|
-
|
|
1311
|
+
cli_echo(json.dumps(report.as_dict(), indent=2), json_mode=True)
|
|
1189
1312
|
else:
|
|
1190
|
-
|
|
1313
|
+
cli_echo(report.format_human())
|
|
1191
1314
|
|
|
1192
1315
|
|
|
1193
1316
|
if __name__ == "__main__":
|
codegraph/cli_output.py
ADDED
|
@@ -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)
|