compass-code 0.1.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.
Files changed (137) hide show
  1. compass_cli/__init__.py +0 -0
  2. compass_cli/commands/__init__.py +0 -0
  3. compass_cli/commands/ingest.py +310 -0
  4. compass_cli/commands/mcp.py +25 -0
  5. compass_cli/commands/query.py +181 -0
  6. compass_cli/commands/serve.py +123 -0
  7. compass_cli/commands/setup.py +171 -0
  8. compass_cli/display.py +231 -0
  9. compass_cli/errors.py +30 -0
  10. compass_cli/main.py +54 -0
  11. compass_cli/progress.py +191 -0
  12. compass_cli/result_views.py +389 -0
  13. compass_cli/types.py +41 -0
  14. compass_code-0.1.0.dist-info/METADATA +72 -0
  15. compass_code-0.1.0.dist-info/RECORD +137 -0
  16. compass_code-0.1.0.dist-info/WHEEL +4 -0
  17. compass_code-0.1.0.dist-info/entry_points.txt +2 -0
  18. compass_core/__init__.py +1 -0
  19. compass_core/contract/__init__.py +6 -0
  20. compass_core/contract/errors.py +116 -0
  21. compass_core/contract/models.py +204 -0
  22. compass_core/contract/params.py +99 -0
  23. compass_core/contract/results.py +370 -0
  24. compass_core/contract/serialize.py +59 -0
  25. compass_core/contract/types.py +76 -0
  26. compass_core/errors.py +148 -0
  27. compass_core/graph/__init__.py +0 -0
  28. compass_core/graph/db.py +205 -0
  29. compass_core/graph/schema.py +165 -0
  30. compass_core/graph/stored_ids.py +133 -0
  31. compass_core/graph/writer.py +482 -0
  32. compass_core/index/__init__.py +1 -0
  33. compass_core/index/freshness.py +55 -0
  34. compass_core/index/locate.py +155 -0
  35. compass_core/index/metadata.py +107 -0
  36. compass_core/ir/__init__.py +0 -0
  37. compass_core/ir/graph.py +65 -0
  38. compass_core/ir/models.py +75 -0
  39. compass_core/languages/__init__.py +1 -0
  40. compass_core/languages/base.py +26 -0
  41. compass_core/languages/python/__init__.py +6 -0
  42. compass_core/languages/python/extractor.py +670 -0
  43. compass_core/languages/python/import_resolver.py +107 -0
  44. compass_core/languages/registry.py +89 -0
  45. compass_core/logger.py +42 -0
  46. compass_core/phases/__init__.py +0 -0
  47. compass_core/phases/base.py +58 -0
  48. compass_core/phases/discovery.py +167 -0
  49. compass_core/phases/parse.py +80 -0
  50. compass_core/phases/persistence.py +33 -0
  51. compass_core/phases/pipeline.py +152 -0
  52. compass_core/phases/resolution.py +572 -0
  53. compass_core/phases/symbol_table.py +36 -0
  54. compass_core/phases/validation.py +76 -0
  55. compass_core/progress.py +45 -0
  56. compass_core/py.typed +0 -0
  57. compass_core/query/__init__.py +1 -0
  58. compass_core/query/cypher.py +92 -0
  59. compass_core/query/detail.py +241 -0
  60. compass_core/query/graph_map.py +127 -0
  61. compass_core/query/impact.py +273 -0
  62. compass_core/query/impact_walk.py +352 -0
  63. compass_core/query/overview.py +221 -0
  64. compass_core/query/references.py +235 -0
  65. compass_core/query/rows.py +378 -0
  66. compass_core/query/search.py +183 -0
  67. compass_core/query/status.py +49 -0
  68. compass_core/query/walk_queries.py +202 -0
  69. compass_core/render.py +408 -0
  70. compass_core/settings.py +61 -0
  71. compass_core/shared_types.py +78 -0
  72. compass_mcp/__init__.py +0 -0
  73. compass_mcp/guide.py +45 -0
  74. compass_mcp/py.typed +0 -0
  75. compass_mcp/server.py +261 -0
  76. compass_server/__init__.py +5 -0
  77. compass_server/errors.py +84 -0
  78. compass_server/main.py +42 -0
  79. compass_server/py.typed +0 -0
  80. compass_server/routers/__init__.py +0 -0
  81. compass_server/routers/query.py +182 -0
  82. compass_server/settings.py +37 -0
  83. compass_server/static/404.html +1 -0
  84. compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
  85. compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
  86. compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
  87. compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
  88. compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
  89. compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
  90. compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
  91. compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
  92. compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
  93. compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
  94. compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
  95. compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
  96. compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
  97. compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
  98. compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
  99. compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
  100. compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  101. compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
  102. compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
  103. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
  104. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
  105. compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
  106. compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
  107. compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
  108. compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
  109. compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
  110. compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
  111. compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
  112. compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
  113. compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
  114. compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
  115. compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
  116. compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
  117. compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
  118. compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
  119. compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
  120. compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
  121. compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
  122. compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
  123. compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
  124. compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
  125. compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
  126. compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
  127. compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
  128. compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
  129. compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
  130. compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
  131. compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
  132. compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
  133. compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
  134. compass_server/static/index.html +1 -0
  135. compass_server/static/index.txt +25 -0
  136. compass_server/web_bundle.py +27 -0
  137. compass_server/web_limits.py +48 -0
@@ -0,0 +1,171 @@
1
+ """The ``compass setup`` command: tell coding agents in a repository about Code Compass.
2
+
3
+ It keeps one managed block, between ``<!-- compass:begin -->`` and ``<!-- compass:end -->``,
4
+ in ``AGENTS.md`` (read by Codex, Cursor and others) and ``CLAUDE.md`` (read by Claude
5
+ Code), creating the files when missing and never touching text outside the markers.
6
+ ``CLAUDE.md`` is left alone when it already imports ``@AGENTS.md``. With ``--mcp`` it also
7
+ registers the MCP server in the project's ``.mcp.json``. Running it again refreshes the
8
+ block and changes nothing else.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+
17
+ import typer
18
+
19
+ from compass_core.index.locate import find_index
20
+ from compass_mcp.guide import AGENT_GUIDE
21
+ from compass_mcp.server import SERVER_NAME
22
+
23
+ BEGIN = "<!-- compass:begin -->"
24
+ END = "<!-- compass:end -->"
25
+ AGENTS_FILE = "AGENTS.md"
26
+ CLAUDE_FILE = "CLAUDE.md"
27
+ CLAUDE_IMPORTS_AGENTS = "@AGENTS.md"
28
+ MCP_FILE = ".mcp.json"
29
+ MCP_ENTRY: dict[str, object] = {"command": "compass", "args": ["mcp"]}
30
+ CODEX_HINT = f"""\
31
+ For Codex, add this to ~/.codex/config.toml:
32
+ [mcp_servers.{SERVER_NAME}]
33
+ command = "compass"
34
+ args = ["mcp"]"""
35
+
36
+
37
+ class Change(StrEnum):
38
+ """What ``setup`` did to one file.
39
+
40
+ Attributes:
41
+ CREATED: The file did not exist and was written.
42
+ UPDATED: The file existed and its managed part was added or replaced.
43
+ UNCHANGED: The file already had exactly this content.
44
+ SKIPPED: The file was deliberately left alone.
45
+ """
46
+
47
+ CREATED = "created"
48
+ UPDATED = "updated"
49
+ UNCHANGED = "unchanged"
50
+ SKIPPED = "skipped"
51
+
52
+
53
+ def managed_block() -> str:
54
+ """The agent guide between the markers, ending with a newline.
55
+
56
+ Returns:
57
+ The block ``setup`` keeps in agent instruction files.
58
+
59
+ Example:
60
+ ``managed_block().splitlines()[0]`` is ``"<!-- compass:begin -->"``.
61
+ """
62
+ return f"{BEGIN}\n{AGENT_GUIDE}{END}\n"
63
+
64
+
65
+ def upsert_block(text: str, block: str) -> str:
66
+ """Replace the managed block in ``text``, or append it; other text is kept as is.
67
+
68
+ Args:
69
+ text: Current file content (may be empty).
70
+ block: The new managed block, markers included.
71
+
72
+ Returns:
73
+ The new content.
74
+
75
+ Example:
76
+ ``upsert_block("# Rules\\n", block)`` returns ``"# Rules\\n\\n" + block``; calling it
77
+ again on that result with a newer block replaces only the block.
78
+ """
79
+ start = text.find(BEGIN)
80
+ end = text.find(END, start)
81
+ if start != -1 and end != -1:
82
+ tail = text[end + len(END) :].removeprefix("\n")
83
+ return text[:start] + block + tail
84
+ if not text.strip():
85
+ return block
86
+ return text.rstrip("\n") + "\n\n" + block
87
+
88
+
89
+ def write_guide(path: Path, block: str) -> Change:
90
+ """Put the managed block into one agent instruction file.
91
+
92
+ Args:
93
+ path: ``AGENTS.md`` or ``CLAUDE.md``.
94
+ block: The managed block.
95
+
96
+ Returns:
97
+ What happened to the file.
98
+ """
99
+ existed = path.exists()
100
+ current = path.read_text(encoding="utf-8") if existed else ""
101
+ if path.name == CLAUDE_FILE and CLAUDE_IMPORTS_AGENTS in current.replace(block, ""):
102
+ return Change.SKIPPED
103
+ updated = upsert_block(current, block)
104
+ if updated == current:
105
+ return Change.UNCHANGED
106
+ path.write_text(updated, encoding="utf-8")
107
+ return Change.UPDATED if existed else Change.CREATED
108
+
109
+
110
+ def register_mcp(path: Path) -> Change:
111
+ """Add or refresh the Code Compass entry in a project's ``.mcp.json``.
112
+
113
+ Other servers and keys are kept. A file that is not a JSON object is not touched.
114
+
115
+ Args:
116
+ path: The ``.mcp.json`` file.
117
+
118
+ Returns:
119
+ What happened to the file.
120
+
121
+ Raises:
122
+ ValueError: The existing file is not a JSON object with an ``mcpServers`` object.
123
+ """
124
+ existed = path.exists()
125
+ data = json.loads(path.read_text(encoding="utf-8")) if existed else {}
126
+ servers = data.setdefault("mcpServers", {}) if isinstance(data, dict) else None
127
+ if not isinstance(servers, dict):
128
+ raise ValueError(f"{path} is not a JSON object with an 'mcpServers' object")
129
+ if servers.get(SERVER_NAME) == MCP_ENTRY:
130
+ return Change.UNCHANGED
131
+ servers[SERVER_NAME] = MCP_ENTRY
132
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
133
+ return Change.UPDATED if existed else Change.CREATED
134
+
135
+
136
+ REPO = typer.Option(
137
+ Path("."), "--repo", file_okay=False, help="Repository to set up; default: here."
138
+ )
139
+
140
+
141
+ def setup(
142
+ repo: Path = REPO,
143
+ mcp: bool = typer.Option(False, "--mcp", help=f"Also register the server in {MCP_FILE}."),
144
+ print_only: bool = typer.Option(
145
+ False, "--print", help="Print the agent guide and write nothing."
146
+ ),
147
+ ) -> None:
148
+ """Tell coding agents in this repository to use Code Compass.
149
+
150
+ Writes a managed section to AGENTS.md and CLAUDE.md (and, with --mcp, the MCP server
151
+ to .mcp.json). Safe to run again; text outside the section is never changed.
152
+ """
153
+ block = managed_block()
154
+ if print_only:
155
+ typer.echo(block, nl=False)
156
+ return
157
+ root = repo.resolve()
158
+ for name in (AGENTS_FILE, CLAUDE_FILE):
159
+ change = write_guide(root / name, block)
160
+ note = f" (it imports {CLAUDE_IMPORTS_AGENTS})" if change is Change.SKIPPED else ""
161
+ typer.echo(f"{name}: {change.value}{note}")
162
+ if mcp:
163
+ try:
164
+ change = register_mcp(root / MCP_FILE)
165
+ except (ValueError, json.JSONDecodeError) as exc:
166
+ typer.echo(f"{MCP_FILE}: not changed: {exc}", err=True)
167
+ raise typer.Exit(code=1) from exc
168
+ typer.echo(f"{MCP_FILE}: {change.value} ({SERVER_NAME} runs `compass mcp`)")
169
+ typer.echo(CODEX_HINT)
170
+ if find_index(root) is None:
171
+ typer.echo("No index yet: run `compass ingest` here before asking questions.")
compass_cli/display.py ADDED
@@ -0,0 +1,231 @@
1
+ """Rich-based display rendering for the Code Compass CLI.
2
+
3
+ All terminal output formatting lives here — commands/ handles argument
4
+ parsing and phase orchestration, this module handles how things look.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console, Group
12
+ from rich.panel import Panel
13
+ from rich.rule import Rule
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from compass_cli.progress import fmt_duration
18
+ from compass_cli.types import IngestSummary
19
+ from compass_core.shared_types import Language
20
+
21
+ GOLD = "color(214)"
22
+ """Accent colour of every Code Compass panel, rule and command name."""
23
+
24
+ console = Console()
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Help
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ _COMMANDS: tuple[tuple[str, str], ...] = (
33
+ ("ingest", "Index a repository (run again after code changes)"),
34
+ ("status", "Show which index is used and whether it is up to date"),
35
+ ("search", "Find files, classes, functions and methods by name"),
36
+ ("describe", "Show one symbol: location, docstring, callers, callees"),
37
+ ("impact", "List what may break if a symbol or file changes"),
38
+ ("overview", "Summarise the repository: entry points and hot spots"),
39
+ ("setup", "Tell coding agents in this repo to use Code Compass"),
40
+ ("mcp", "Serve the code graph to coding agents over MCP"),
41
+ ("serve", "Start the API server and the web UI"),
42
+ ("help", "Show this list"),
43
+ )
44
+ """Commands listed by ``compass`` with no arguments, in the order a user needs them."""
45
+
46
+
47
+ def help_commands() -> None:
48
+ """Print the available CLI commands with one line each."""
49
+ width = max(len(name) for name, _ in _COMMANDS)
50
+ console.print(f"[bold {GOLD}]Code Compass[/bold {GOLD}] — Navigate your codebase with ease\n")
51
+ console.print("[bold]Usage:[/bold] compass [OPTIONS] COMMAND [ARGS]...\n")
52
+ console.print("[bold]Commands:[/bold]")
53
+ for name, summary in _COMMANDS:
54
+ console.print(f" [bold {GOLD}]{name:<{width}}[/bold {GOLD}] {summary}")
55
+ console.print()
56
+ console.print("[dim]Run [bold]compass COMMAND --help[/bold] for a command's options.[/dim]")
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Ingest command display helpers
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def ingest_header(repo_path: Path) -> None:
65
+ """Print the opening line for an ingest run.
66
+
67
+ Args:
68
+ repo_path: The filesystem path to the repository being ingested.
69
+ """
70
+ console.print(f"\n[dim]Ingesting[/dim] [bold]{repo_path}[/bold]\n")
71
+
72
+
73
+ def phase_header(num: int, total: int, title: str) -> None:
74
+ """Print a centered phase separator rule.
75
+
76
+ Example output: `Phase 1 of 2 · Ingestion` (centered with a gold rule).
77
+
78
+ Args:
79
+ num: The 1-indexed phase number currently starting.
80
+ total: The total number of phases in the run.
81
+ title: A descriptive name for the current phase.
82
+ """
83
+ console.print(
84
+ Rule(
85
+ f"[bold {GOLD}]Phase {num} of {total}[/bold {GOLD}]"
86
+ f"[dim] · [/dim]"
87
+ f"[bold]{title}[/bold]",
88
+ style=GOLD,
89
+ align="center",
90
+ )
91
+ )
92
+ console.print()
93
+
94
+
95
+ def _language_grid(language_counts: dict[Language, int]) -> Table:
96
+ """Lay out a "Languages" label followed by a language -> file-count
97
+ breakdown, three columns wide."""
98
+ grid = Table.grid(padding=(0, 3))
99
+ grid.add_column(style="dim", min_width=13)
100
+ for _ in range(3):
101
+ grid.add_column(style="bold")
102
+ grid.add_column(style="dim", min_width=6)
103
+
104
+ items = sorted(language_counts.items(), key=lambda kv: kv[1], reverse=True)
105
+ for i in range(0, len(items), 3):
106
+ row: list[str] = ["Languages" if i == 0 else ""]
107
+ for lang, count in items[i : i + 3]:
108
+ row += [lang, f"{count:,}"]
109
+ row += [""] * (7 - len(row))
110
+ grid.add_row(*row)
111
+ return grid
112
+
113
+
114
+ def summary(summary: IngestSummary) -> None:
115
+ """Print the final summary in a gold-bordered panel.
116
+
117
+ Args:
118
+ summary: Aggregate results of the ingest run to display.
119
+ """
120
+ left = Table.grid(padding=(0, 3))
121
+ left.add_column(style="dim", min_width=13)
122
+ left.add_column(style="bold")
123
+ left.add_row("Files indexed", f"{summary.file_count:,}")
124
+ left.add_row("Classes", f"{summary.class_count:,}")
125
+ left.add_row("Functions", f"{summary.function_count:,}")
126
+ left.add_row("Skipped", str(summary.skipped))
127
+ left.add_row("Elapsed", fmt_duration(summary.elapsed))
128
+
129
+ right = Table.grid(padding=(0, 3))
130
+ right.add_column(style="dim", min_width=9)
131
+ right.add_column(style="bold")
132
+ right.add_row("Nodes", f"{summary.node_count:,}")
133
+ right.add_row("Edges", f"{summary.edge_count:,}")
134
+ if summary.dropped_edges:
135
+ right.add_row("Skipped", f"{summary.dropped_edges:,} edges")
136
+ right.add_row("Resolved", f"{summary.resolved_pct:.0%}")
137
+ right.add_row("Orphans", f"{summary.orphan_count:,}")
138
+ right.add_row("DB size", fmt_size(summary.db_size))
139
+
140
+ columns = Table.grid(padding=(0, 6))
141
+ columns.add_column()
142
+ columns.add_column()
143
+ columns.add_row(left, right)
144
+
145
+ db_line = Text(no_wrap=True, overflow="fold")
146
+ db_line.append("Database ", style="dim")
147
+ db_line.append(str(summary.db_path), style="bold")
148
+
149
+ parts: list[Table | Text] = [columns, Text("")]
150
+ if summary.language_counts:
151
+ parts.append(_language_grid(summary.language_counts))
152
+ parts.append(Text(""))
153
+ parts.append(db_line)
154
+
155
+ console.print(
156
+ Panel(
157
+ Group(*parts),
158
+ title=f"[bold {GOLD}]compass ingest complete[/bold {GOLD}]",
159
+ border_style=GOLD,
160
+ padding=(1, 2),
161
+ expand=True,
162
+ )
163
+ )
164
+ if summary.dropped_edges:
165
+ console.print(f" [dim]{skipped_edges_note(summary.dropped_edges)}[/dim]", soft_wrap=True)
166
+ console.print()
167
+
168
+
169
+ def skipped_edges_note(count: int) -> str:
170
+ """Explain why some resolved edges are missing from the database.
171
+
172
+ Args:
173
+ count: Number of edges the writer could not store.
174
+
175
+ Returns:
176
+ A one-line explanation, e.g. ``"3 edges skipped (callee is not a stored
177
+ function, e.g. class constructor calls)"``.
178
+ """
179
+ noun = "edge" if count == 1 else "edges"
180
+ return (
181
+ f"{count:,} {noun} skipped (callee is not a stored function, e.g. class constructor calls)"
182
+ )
183
+
184
+
185
+ NEXT_STEPS: tuple[tuple[str, str], ...] = (
186
+ ("compass overview", "See entry points and hot spots"),
187
+ ("compass search NAME", "Find where something is defined"),
188
+ ("compass setup", "Let coding agents (Claude Code, Codex) use this index"),
189
+ ("compass serve", "Explore the graph in the web UI"),
190
+ ("compass help", "See every command"),
191
+ )
192
+ """Commands suggested after an ingest, most useful first."""
193
+
194
+
195
+ def next_steps() -> None:
196
+ """Print the commands worth running after a successful ingest."""
197
+ width = max(len(command) for command, _ in NEXT_STEPS)
198
+ console.print("[bold]What's next:[/bold]")
199
+ for command, summary in NEXT_STEPS:
200
+ console.print(f" [bold {GOLD}]{command:<{width}}[/bold {GOLD}] {summary}")
201
+ console.print()
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Utilities
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def error(message: str) -> None:
210
+ """Print a formatted error message.
211
+
212
+ Args:
213
+ message: The error text to display.
214
+ """
215
+ console.print(f"[bold red]Error:[/bold red] {message}")
216
+
217
+
218
+ def fmt_size(size_bytes: int) -> str:
219
+ """Format a byte count as a human-readable string.
220
+
221
+ Args:
222
+ size_bytes: The number of bytes to format.
223
+
224
+ Returns:
225
+ A formatted string (e.g., "1.5 KB", "300 B").
226
+ """
227
+ if size_bytes < 1024:
228
+ return f"{size_bytes} B"
229
+ if size_bytes < 1024 * 1024:
230
+ return f"{size_bytes / 1024:.1f} KB"
231
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
compass_cli/errors.py ADDED
@@ -0,0 +1,30 @@
1
+ """Shared CLI error-boundary decorator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from typing import TypeVar
8
+
9
+ import typer
10
+
11
+ from compass_cli import display
12
+
13
+ F = TypeVar("F", bound=Callable[..., None])
14
+
15
+
16
+ def handle_errors(func: F) -> F:
17
+ """Catch expected OS-level failures, print a clean message, and exit(1).
18
+
19
+ Anything not an OSError still propagates with a full traceback.
20
+ """
21
+
22
+ @functools.wraps(func)
23
+ def wrapper(*args: object, **kwargs: object) -> None:
24
+ try:
25
+ func(*args, **kwargs)
26
+ except OSError as exc:
27
+ display.error(str(exc))
28
+ raise typer.Exit(code=1) from exc
29
+
30
+ return wrapper # type: ignore[return-value] # functools.wraps keeps the signature; mypy cannot prove wrapper is F
compass_cli/main.py ADDED
@@ -0,0 +1,54 @@
1
+ from importlib.metadata import version
2
+
3
+ import typer
4
+
5
+ from compass_cli import display
6
+ from compass_cli.commands.ingest import ingest
7
+ from compass_cli.commands.mcp import mcp
8
+ from compass_cli.commands.query import describe, impact, overview, search, status
9
+ from compass_cli.commands.serve import serve
10
+ from compass_cli.commands.setup import setup
11
+
12
+ app = typer.Typer(
13
+ help="Code Compass CLI",
14
+ no_args_is_help=False,
15
+ context_settings={"help_option_names": ["-h", "--help"]},
16
+ )
17
+
18
+
19
+ DISTRIBUTION = "compass-code"
20
+ """The PyPI package that installs the ``compass`` command."""
21
+
22
+
23
+ def _print_version(value: bool) -> None:
24
+ if value:
25
+ typer.echo(f"{DISTRIBUTION} {version(DISTRIBUTION)}")
26
+ raise typer.Exit()
27
+
28
+
29
+ VERSION = typer.Option(
30
+ False, "--version", "-V", callback=_print_version, is_eager=True, help="Print the version."
31
+ )
32
+
33
+
34
+ @app.callback(invoke_without_command=True)
35
+ def main(ctx: typer.Context, show_version: bool = VERSION) -> None:
36
+ """Code Compass — Navigate your codebase with ease."""
37
+ if ctx.invoked_subcommand is None:
38
+ display.help_commands()
39
+
40
+
41
+ app.command(name="ingest")(ingest)
42
+ app.command(name="status")(status)
43
+ app.command(name="search")(search)
44
+ app.command(name="describe")(describe)
45
+ app.command(name="impact")(impact)
46
+ app.command(name="overview")(overview)
47
+ app.command(name="serve")(serve)
48
+ app.command(name="mcp")(mcp)
49
+ app.command(name="setup")(setup)
50
+ app.command(name="help")(display.help_commands)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ app()
@@ -0,0 +1,191 @@
1
+ """Rich Live table reporter for the CLI ingest pipeline.
2
+
3
+ ``IngestReporter`` owns a single Rich ``Live`` table with one row per
4
+ pipeline stage (STAGE / RESULT / TIME). A row appears the moment its stage
5
+ starts, shows a live current/total counter while running (for stages that
6
+ report granular progress — parse, persistence), and freezes to its final
7
+ summary text once the stage completes via ``complete()``.
8
+
9
+ Adding a new phase
10
+ -------------------
11
+ 1. Add a ``(phase_name, stage_label)`` entry to ``_STAGE_LABELS``.
12
+ 2. If the phase reports granular progress, map its ``ProgressReporter``
13
+ phase name(s) to it in ``_REPORTER_TO_STAGE``.
14
+ 3. Call ``reporter.complete(phase_name, primary, secondary, warn=...)`` from
15
+ the CLI once that phase finishes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from dataclasses import dataclass
22
+
23
+ from rich.console import Console, Group, RenderableType
24
+ from rich.live import Live
25
+ from rich.spinner import Spinner
26
+ from rich.table import Table
27
+ from rich.text import Text
28
+
29
+ from compass_core.progress import Phase
30
+
31
+ _GOLD = "color(214)"
32
+ _GREEN = "green"
33
+ _WARN = "color(208)"
34
+ _DIM = "dim"
35
+ _BAR_WIDTH = 14
36
+ _DOT_THRESHOLD = 0.12
37
+
38
+ _STAGE_LABELS: dict[str, str] = {
39
+ "discovery": "discover",
40
+ "parse": "parse",
41
+ "symbol_table": "symbols",
42
+ "resolution": "resolve",
43
+ "validation": "validate",
44
+ "persistence": "write",
45
+ }
46
+ _STAGE_ORDER = list(_STAGE_LABELS)
47
+
48
+ # ProgressReporter phase names (fine-grained, item-level) mapped onto the
49
+ # pipeline stage whose row they should update while it runs.
50
+ _REPORTER_TO_STAGE: dict[str, str] = {
51
+ "parse": "parse",
52
+ "write_nodes": "persistence",
53
+ "write_edges": "persistence",
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class _StageRow:
59
+ status: str = "pending" # pending | running | done
60
+ primary: str = ""
61
+ secondary: str = ""
62
+ warn: bool = False
63
+ start: float | None = None
64
+ duration: float | None = None
65
+ current: int = 0
66
+ total: int = 0
67
+
68
+
69
+ def fmt_duration(seconds: float) -> str:
70
+ if seconds >= 60:
71
+ minutes, rest = divmod(seconds, 60)
72
+ return f"{int(minutes)}m {rest:.0f}s"
73
+ return f"{seconds:.1f}s"
74
+
75
+
76
+ def _bar(frac: float, *, style: str) -> Text:
77
+ frac = max(0.0, min(1.0, frac))
78
+ if frac < _DOT_THRESHOLD:
79
+ return Text("●", style=_DIM)
80
+ filled = max(1, round(frac * _BAR_WIDTH))
81
+ return Text("━" * filled, style=style)
82
+
83
+
84
+ class IngestReporter:
85
+ """Live Rich table of pipeline stages; also satisfies ``ProgressReporter``."""
86
+
87
+ def __init__(self, console: Console | None = None) -> None:
88
+ self._rows: dict[str, _StageRow] = {name: _StageRow() for name in _STAGE_ORDER}
89
+ self._show_total = False
90
+ self._live = Live(self._render(), console=console, refresh_per_second=8, transient=False)
91
+
92
+ def __enter__(self) -> IngestReporter:
93
+ self._live.start()
94
+ return self
95
+
96
+ def __exit__(self, *_: object) -> None:
97
+ self._live.stop()
98
+
99
+ # -- ProgressReporter protocol -----------------------------------------
100
+
101
+ def update(self, phase: Phase, current: int, total: int) -> None:
102
+ """Update the live current/total counter for the stage *phase* maps to."""
103
+ stage = _REPORTER_TO_STAGE.get(phase)
104
+ if stage is None:
105
+ return
106
+ row = self._rows[stage]
107
+ if row.status == "pending":
108
+ row.status = "running"
109
+ row.start = time.monotonic()
110
+ row.current, row.total = current, total
111
+ self._refresh()
112
+
113
+ # -- CLI-facing API -------------------------------------------------------
114
+
115
+ def complete(self, phase: str, primary: str, secondary: str, *, warn: bool = False) -> None:
116
+ """Freeze *phase*'s row with its final summary text and elapsed duration."""
117
+ row = self._rows[phase]
118
+ now = time.monotonic()
119
+ if row.start is None:
120
+ row.start = now
121
+ row.duration = now - row.start
122
+ row.status = "done"
123
+ row.primary = primary
124
+ row.secondary = secondary
125
+ row.warn = warn
126
+ self._refresh()
127
+
128
+ def finalize(self) -> None:
129
+ """Show the total row and stop the live display."""
130
+ self._show_total = True
131
+ self._refresh()
132
+ self._live.stop()
133
+
134
+ @property
135
+ def total_duration(self) -> float:
136
+ return sum(r.duration or 0.0 for r in self._rows.values())
137
+
138
+ # -- rendering --------------------------------------------------------
139
+
140
+ def _refresh(self) -> None:
141
+ self._live.update(self._render())
142
+
143
+ def _render(self) -> RenderableType:
144
+ table = Table.grid(padding=(0, 3))
145
+ table.add_column(min_width=9)
146
+ table.add_column(min_width=26)
147
+ table.add_column(justify="right", min_width=10)
148
+
149
+ table.add_row(
150
+ Text("STAGE", style=_DIM), Text("RESULT", style=_DIM), Text("TIME", style=_DIM)
151
+ )
152
+
153
+ max_duration = max((r.duration for r in self._rows.values() if r.duration), default=0.0)
154
+ for name in _STAGE_ORDER:
155
+ row = self._rows[name]
156
+ if row.status == "pending":
157
+ continue
158
+ label = _STAGE_LABELS[name]
159
+
160
+ if row.status == "running":
161
+ stage_text = Text(label, style=f"bold {_GOLD}")
162
+ progress_text = Text(
163
+ f"{row.current:,} / {row.total:,}" if row.total else "working…"
164
+ )
165
+ table.add_row(stage_text, progress_text, Spinner("dots", style=_GOLD))
166
+ continue
167
+
168
+ style = _WARN if row.warn else "bold"
169
+ stage_text = Text(label, style=style)
170
+ result: RenderableType = (
171
+ Group(Text(row.primary, style=style), Text(row.secondary, style=_DIM))
172
+ if row.secondary
173
+ else Text(row.primary, style=style)
174
+ )
175
+ frac = ((row.duration or 0.0) / max_duration) if max_duration else 0.0
176
+ bar_style = _WARN if row.warn else (_GREEN if frac >= _DOT_THRESHOLD else _DIM)
177
+ time_cell = Group(
178
+ Text(fmt_duration(row.duration or 0.0), justify="right"),
179
+ _bar(frac, style=bar_style),
180
+ )
181
+ table.add_row(stage_text, result, time_cell)
182
+ table.add_row("", "", "")
183
+
184
+ if self._show_total:
185
+ table.add_row("", "", "")
186
+ table.add_row(
187
+ Text("total", style=_DIM),
188
+ Text(""),
189
+ Text(fmt_duration(self.total_duration), style="bold", justify="right"),
190
+ )
191
+ return table