seam-code 0.3.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 (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/cli/output.py ADDED
@@ -0,0 +1,137 @@
1
+ """Agent-output contract for the Seam CLI.
2
+
3
+ This module is the single source of truth for structured output emitted
4
+ by CLI commands when --json or --quiet flags are passed.
5
+
6
+ Design goals (from PRD §slice-2):
7
+ - Consistent JSON envelope across ALL read commands: {"ok": true, "data": ...}
8
+ on success, {"ok": false, "error": {"code": ..., "message": ...}} on failure.
9
+ - Errors in --json mode go to STDOUT as JSON + non-zero exit (not stderr text).
10
+ This is where Seam leapfrogs CodeGraph: CodeGraph emits ANSI errors to stderr
11
+ even in JSON mode; Seam always gives a machine-parseable envelope.
12
+ - --quiet emits bare, one-per-line values for piping into other tools.
13
+ - --json and --quiet are mutually exclusive.
14
+
15
+ WHY these as module-level functions rather than a class:
16
+ Simplicity. There is no shared state between invocations. A module with
17
+ plain functions is the minimum complexity that satisfies the spec.
18
+
19
+ Stable error codes (reuse vocabulary from existing MCP handlers):
20
+ NO_INDEX — index not found; run seam init first
21
+ INVALID_INPUT — blank/missing required argument (user-supplied bad input)
22
+ INVALID_QUERY — bad FTS5 syntax (rare after Phase 3 sanitization)
23
+ NOT_A_GIT_REPO — changes command outside a git repository
24
+ DB_ERROR — database file exists but could not be opened (corrupted, locked, etc.)
25
+ Distinct from INVALID_INPUT (which implies the user sent bad data).
26
+ """
27
+
28
+ import json
29
+ import sys
30
+ from typing import Any
31
+
32
+ import typer
33
+
34
+ # ── Envelope builders ──────────────────────────────────────────────────────────
35
+
36
+
37
+ def build_success_envelope(data: Any) -> dict[str, Any]:
38
+ """Build a success envelope: {"ok": true, "data": <payload>}.
39
+
40
+ 'ok' is always the first key (deterministic ordering for diff-readability).
41
+ The payload can be any JSON-serializable value (dict, list, None, …).
42
+ """
43
+ # Use insertion-ordered dict (Python 3.7+ guaranteed) so "ok" comes first.
44
+ return {"ok": True, "data": data}
45
+
46
+
47
+ def build_error_envelope(code: str, message: str) -> dict[str, Any]:
48
+ """Build an error envelope: {"ok": false, "error": {"code": ..., "message": ...}}.
49
+
50
+ 'ok' is always the first key. 'code' is a STABLE_UPPER_SNAKE identifier
51
+ that callers can branch on without parsing human-readable text.
52
+ """
53
+ return {"ok": False, "error": {"code": code, "message": message}}
54
+
55
+
56
+ # ── Mutual-exclusion guard ─────────────────────────────────────────────────────
57
+
58
+
59
+ def check_mutual_exclusion(json_: bool, quiet: bool) -> None:
60
+ """Raise ValueError when --json and --quiet are both set.
61
+
62
+ WHY: Having two machine-output modes active simultaneously is undefined
63
+ behaviour. The caller should emit the error envelope (since --json was
64
+ requested) and exit 1 — handled by the CLI command, not here.
65
+ """
66
+ if json_ and quiet:
67
+ raise ValueError("--json and --quiet are mutually exclusive; pick one")
68
+
69
+
70
+ # ── JSON emitter ───────────────────────────────────────────────────────────────
71
+
72
+
73
+ def emit_json(data: Any) -> None:
74
+ """Write a compact JSON success envelope to stdout.
75
+
76
+ WHY compact (no indent): agents parse structured output, not humans.
77
+ Compact keeps tokens minimal. A trailing newline is added for shell
78
+ compatibility (most tools expect it).
79
+ """
80
+ envelope = build_success_envelope(data)
81
+ # ensure_ascii=False: preserve non-ASCII symbol names as-is
82
+ sys.stdout.write(json.dumps(envelope, ensure_ascii=False) + "\n")
83
+
84
+
85
+ def emit_json_error(code: str, message: str) -> None:
86
+ """Write a compact JSON error envelope to stdout and exit with code 1.
87
+
88
+ WHY stdout (not stderr): agents read stdout. The non-zero exit code
89
+ signals failure to shell pipelines/CI; the JSON body gives the reason.
90
+ This is the key leapfrog over CodeGraph, which writes ANSI text to stderr.
91
+ """
92
+ envelope = build_error_envelope(code, message)
93
+ sys.stdout.write(json.dumps(envelope, ensure_ascii=False) + "\n")
94
+ raise typer.Exit(code=1)
95
+
96
+
97
+ # ── Quiet renderer ─────────────────────────────────────────────────────────────
98
+
99
+
100
+ def quiet_lines(data: Any, field: str | None = None) -> list[str]:
101
+ """Return bare line strings for --quiet mode rendering.
102
+
103
+ Rules:
104
+ - If data is a list of strings: return them as-is.
105
+ - If data is a list of dicts: return str(item[field]) per item.
106
+ field must be provided when data is a list of dicts.
107
+ - If data is a dict: return [str(data[field])].
108
+ field must be provided when data is a dict.
109
+ - Empty list: return [].
110
+
111
+ WHY return list instead of printing: callers decide how to format
112
+ (newlines, prefixes, etc.) and can assert the list in tests without
113
+ capturing stdout.
114
+ """
115
+ if isinstance(data, list):
116
+ if not data:
117
+ return []
118
+ # Plain string list — return as-is
119
+ if isinstance(data[0], str):
120
+ return list(data)
121
+ # List of dicts — extract the named field
122
+ return [str(item[field]) for item in data if field is not None]
123
+
124
+ if isinstance(data, dict) and field is not None:
125
+ return [str(data[field])]
126
+
127
+ return []
128
+
129
+
130
+ def print_quiet(data: Any, field: str | None = None) -> None:
131
+ """Print quiet_lines output to stdout, one line each.
132
+
133
+ Helper that CLI commands call after quiet_lines() to avoid
134
+ duplicating the sys.stdout.write loop in every command.
135
+ """
136
+ for line in quiet_lines(data, field=field):
137
+ sys.stdout.write(line + "\n")
seam/cli/read.py ADDED
@@ -0,0 +1,244 @@
1
+ """CLI read commands that complete the terminal-only surface: `seam query`,
2
+ `seam search`, `seam context`.
3
+
4
+ These three were previously MCP-only. They reuse the same transport-agnostic
5
+ handlers that power the MCP tools (handle_seam_query / _search / _context in
6
+ seam/server/tools.py), which query the SQLite index directly — so they work with
7
+ NO MCP server running. Defined here (not main.py) because main.py is already large;
8
+ registered onto the app in main.py.
9
+ """
10
+
11
+ import sqlite3
12
+ from pathlib import Path
13
+ from typing import NoReturn
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ import seam.config as config
20
+ from seam.cli.output import (
21
+ check_mutual_exclusion,
22
+ emit_json,
23
+ emit_json_error,
24
+ print_quiet,
25
+ )
26
+ from seam.indexer.db import connect
27
+ from seam.server.tools import handle_seam_context, handle_seam_query, handle_seam_search
28
+
29
+ console = Console()
30
+
31
+ _QUERY_LIMIT_DEFAULT = 10
32
+ _SEARCH_LIMIT_DEFAULT = 20
33
+
34
+
35
+ def _open_index(path: str, db_dir: str, json_: bool) -> tuple[sqlite3.Connection, Path]:
36
+ """Resolve the index, guard NO_INDEX/DB_ERROR, and return (conn, project_root).
37
+
38
+ Mirrors the resolution + error envelope used by every other read command so
39
+ the CLI contract (NO_INDEX / DB_ERROR codes, --json on stdout) stays uniform.
40
+ """
41
+ project_root = Path(path).resolve()
42
+ db_root = Path(db_dir).resolve() if db_dir else project_root
43
+ db_path = config.get_db_path(db_root)
44
+
45
+ if not db_path.exists():
46
+ if json_:
47
+ emit_json_error("NO_INDEX", "No index found. Run 'seam init' first.")
48
+ console.print("[red]No index found.[/red] Run [bold]seam init[/bold] first.")
49
+ raise typer.Exit(code=1)
50
+
51
+ try:
52
+ conn = connect(db_path)
53
+ except sqlite3.Error as exc:
54
+ if json_:
55
+ emit_json_error("DB_ERROR", f"Failed to open database: {exc}")
56
+ console.print(f"[red]Failed to open database:[/red] {exc}")
57
+ raise typer.Exit(code=1) from exc
58
+ return conn, project_root
59
+
60
+
61
+ def _emit_error_dict(result: dict, json_: bool) -> NoReturn:
62
+ """A handler returned an {error, message} dict (blank/invalid input) → exit 1 (always raises)."""
63
+ if json_:
64
+ emit_json_error(result["error"], result.get("message", ""))
65
+ console.print(f"[red]Error:[/red] {result.get('message', result['error'])}")
66
+ raise typer.Exit(code=1)
67
+
68
+
69
+ def _render_hits(rows: list[dict], title: str) -> None:
70
+ """Rich table for query/search results (symbol · location · score)."""
71
+ if not rows:
72
+ console.print("[dim]No matches.[/dim]")
73
+ return
74
+ table = Table(title=title)
75
+ table.add_column("symbol", style="bold")
76
+ table.add_column("uid", style="cyan") # P6c: stable handle for context/impact/trace
77
+ table.add_column("location", style="dim")
78
+ table.add_column("score", justify="right")
79
+ for row in rows:
80
+ loc = f"{row.get('file', '?')}:{row.get('line', '?')}"
81
+ table.add_row(
82
+ str(row.get("symbol", "")),
83
+ str(row.get("uid", "")),
84
+ loc,
85
+ f"{row.get('score', 0):.1f}",
86
+ )
87
+ console.print(table)
88
+
89
+
90
+ def search_command(
91
+ text: str = typer.Argument(..., help="Keywords to full-text search (FTS5)."),
92
+ path: str = typer.Option(".", "--path", help="Project root (default: current directory)."),
93
+ db_dir: str = typer.Option("", "--db-dir", help="Override DB directory."),
94
+ limit: int = typer.Option(_SEARCH_LIMIT_DEFAULT, "--limit", help="Max results."),
95
+ no_semantic: bool = typer.Option(
96
+ False,
97
+ "--no-semantic",
98
+ help=(
99
+ "Force keyword-only FTS5 search, even when embeddings exist. "
100
+ "Useful for comparing keyword vs. hybrid results."
101
+ ),
102
+ ),
103
+ json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
104
+ quiet: bool = typer.Option(False, "--quiet", help="Print bare symbol names, one per line."),
105
+ ) -> None:
106
+ """Full-text search over indexed symbol names, docstrings, and signatures (no MCP needed).
107
+
108
+ By default auto-uses semantic hybrid search when embeddings exist (SEAM_SEMANTIC=on).
109
+ Use --no-semantic to force keyword-only FTS5, regardless of whether embeddings are present.
110
+ """
111
+ try:
112
+ check_mutual_exclusion(json_=json_, quiet=quiet)
113
+ except ValueError as exc:
114
+ emit_json_error("INVALID_INPUT", str(exc))
115
+
116
+ conn, project_root = _open_index(path, db_dir, json_)
117
+ try:
118
+ # --no-semantic: pass semantic=False to bypass the hybrid path without mutating
119
+ # global config. This is safe for both single-threaded CLI use and any future
120
+ # concurrent context. (DRIFT-1 fix: no more module-attribute patching.)
121
+ result = handle_seam_search(
122
+ conn, text, project_root, limit=limit, semantic=not no_semantic
123
+ )
124
+ finally:
125
+ conn.close()
126
+
127
+ # search/query return a list of hits OR an {error,message} dict — any dict is an error.
128
+ if isinstance(result, dict):
129
+ _emit_error_dict(result, json_)
130
+ if json_:
131
+ emit_json(result)
132
+ return
133
+ if quiet:
134
+ print_quiet(result, field="symbol")
135
+ return
136
+ _render_hits(result, f"search: {text}")
137
+
138
+
139
+ def query_command(
140
+ concept: str = typer.Argument(..., help="Concept/keywords (hybrid FTS + 1-hop graph)."),
141
+ path: str = typer.Option(".", "--path", help="Project root (default: current directory)."),
142
+ db_dir: str = typer.Option("", "--db-dir", help="Override DB directory."),
143
+ limit: int = typer.Option(_QUERY_LIMIT_DEFAULT, "--limit", help="Max results."),
144
+ no_semantic: bool = typer.Option(
145
+ False,
146
+ "--no-semantic",
147
+ help=(
148
+ "Force keyword-only FTS5 search, even when embeddings exist. "
149
+ "Useful for comparing keyword vs. hybrid results."
150
+ ),
151
+ ),
152
+ json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
153
+ quiet: bool = typer.Option(False, "--quiet", help="Print bare symbol names, one per line."),
154
+ ) -> None:
155
+ """Hybrid search (FTS5 + 1-hop graph expansion) for code related to a concept (no MCP needed)."""
156
+ try:
157
+ check_mutual_exclusion(json_=json_, quiet=quiet)
158
+ except ValueError as exc:
159
+ emit_json_error("INVALID_INPUT", str(exc))
160
+
161
+ conn, project_root = _open_index(path, db_dir, json_)
162
+ try:
163
+ result = handle_seam_query(
164
+ conn, concept, project_root, limit=limit, semantic=not no_semantic
165
+ )
166
+ finally:
167
+ conn.close()
168
+
169
+ # search/query return a list of hits OR an {error,message} dict — any dict is an error.
170
+ if isinstance(result, dict):
171
+ _emit_error_dict(result, json_)
172
+ if json_:
173
+ emit_json(result)
174
+ return
175
+ if quiet:
176
+ print_quiet(result, field="symbol")
177
+ return
178
+ _render_hits(result, f"query: {concept}")
179
+
180
+
181
+ def context_command(
182
+ symbol: str = typer.Argument(..., help="Symbol name to get the 360-degree view for."),
183
+ path: str = typer.Option(".", "--path", help="Project root (default: current directory)."),
184
+ db_dir: str = typer.Option("", "--db-dir", help="Override DB directory."),
185
+ lean: bool = typer.Option(
186
+ False,
187
+ "--lean",
188
+ help="Omit heavy enrichment (decorators, is_exported, visibility, qualified_name).",
189
+ ),
190
+ json_: bool = typer.Option(False, "--json", help="Emit structured JSON envelope to stdout."),
191
+ quiet: bool = typer.Option(False, "--quiet", help="Print key facts, one per line."),
192
+ ) -> None:
193
+ """360-degree view of a symbol: callers, callees, location, cluster, enrichment (no MCP needed)."""
194
+ try:
195
+ check_mutual_exclusion(json_=json_, quiet=quiet)
196
+ except ValueError as exc:
197
+ emit_json_error("INVALID_INPUT", str(exc))
198
+
199
+ conn, project_root = _open_index(path, db_dir, json_)
200
+ try:
201
+ # --lean maps to verbose=False — byte-identical to the MCP tool with verbose=False.
202
+ result = handle_seam_context(conn, symbol, project_root, verbose=not lean)
203
+ finally:
204
+ conn.close()
205
+
206
+ if isinstance(result, dict) and result.get("error"):
207
+ _emit_error_dict(result, json_)
208
+
209
+ # Not found: success envelope {found:false} (NOT an error code) — mirrors the MCP
210
+ # seam_context contract so a missing symbol is a valid, parseable answer.
211
+ if result is None:
212
+ if json_:
213
+ emit_json({"found": False, "symbol": symbol})
214
+ return
215
+ console.print(
216
+ f"[yellow]Symbol '{symbol}' not found in the index[/yellow]"
217
+ " — check the name or run 'seam init'."
218
+ )
219
+ return
220
+
221
+ if json_:
222
+ emit_json(result)
223
+ return
224
+ if quiet:
225
+ # Terse: the most useful identity facts for a human scanning output.
226
+ for key in ("symbol", "kind", "file", "line", "signature"):
227
+ if result.get(key) is not None:
228
+ console.print(f"{key}: {result[key]}")
229
+ return
230
+ _render_context(result)
231
+
232
+
233
+ def _render_context(ctx: dict) -> None:
234
+ """Rich rendering of a single seam_context result."""
235
+ console.print(f"[bold]{ctx.get('symbol')}[/bold] [dim]{ctx.get('kind')}[/dim]")
236
+ console.print(f" [dim]{ctx.get('file')}:{ctx.get('line')}[/dim]")
237
+ if ctx.get("signature"):
238
+ console.print(f" signature: {ctx['signature']}")
239
+ if ctx.get("docstring"):
240
+ console.print(f" doc: {ctx['docstring']}")
241
+ if ctx.get("cluster_label"):
242
+ console.print(f" cluster: {ctx['cluster_label']}")
243
+ console.print(f" callers ({len(ctx.get('callers', []))}): {', '.join(ctx.get('callers', [])) or '—'}")
244
+ console.print(f" callees ({len(ctx.get('callees', []))}): {', '.join(ctx.get('callees', [])) or '—'}")
seam/cli/serve.py ADDED
@@ -0,0 +1,145 @@
1
+ """'seam serve' command — start the local Seam Explorer web server.
2
+
3
+ WHY a separate module: main.py is large (>1000 lines). Keeping per-command logic in
4
+ sibling modules mirrors the pattern established by seam/cli/read.py (query/search/context)
5
+ and seam/cli/install.py.
6
+
7
+ Lazy imports
8
+ ------------
9
+ fastapi, uvicorn, and seam.server.web are imported INSIDE serve_command(), not at module
10
+ top-level. This mirrors the _load_create_server() pattern in main.py for the MCP server:
11
+ the entire CLI must remain usable when the `web` extra is not installed. Only `seam serve`
12
+ needs fastapi/uvicorn — a missing extra yields a clear install hint rather than a crash
13
+ at CLI startup.
14
+
15
+ Binding
16
+ -------
17
+ 127.0.0.1-only is the default and only supported host. The `--host` flag is intentionally
18
+ restricted here (it accepts any string but the CLAUDE.md non-negotiable says "127.0.0.1-only").
19
+ A user who changes --host to 0.0.0.0 does so at their own risk; the serve command does not
20
+ add network-level auth, so the docs and default strongly discourage it.
21
+ """
22
+
23
+ import webbrowser
24
+ from pathlib import Path
25
+
26
+ import typer
27
+ from rich.console import Console
28
+
29
+ import seam.config as config
30
+
31
+ console = Console()
32
+ err_console = Console(stderr=True)
33
+
34
+
35
+ def _load_web_app_factory(): # type: ignore[return]
36
+ """Lazy-import seam.server.web and return create_web_app.
37
+
38
+ WHY lazy: `fastapi` is an optional extra (`seam-mcp[web]`). Importing at module
39
+ top-level would crash the CLI on startup for users who installed without [web].
40
+ Only `seam serve` needs it, so the import lives here, not at the top of the file.
41
+
42
+ Returns create_web_app on success.
43
+ Prints a red error + install hint and raises typer.Exit(1) on ImportError.
44
+ """
45
+ try:
46
+ from seam.server.web import create_web_app # noqa: PLC0415
47
+
48
+ return create_web_app
49
+ except ImportError:
50
+ # WHY escaped brackets (\\[): Rich interprets [web] as a markup tag and drops it.
51
+ # Using \\[ tells Rich to render a literal '[' so the install command is displayed
52
+ # correctly in the terminal. CliRunner captures the rendered (markup-stripped) text.
53
+ err_console.print(
54
+ "[red]Web server support is not installed.[/red]\n"
55
+ "Install it with: [bold]pip install 'seam-mcp\\[web]'[/bold]\n"
56
+ " (from source: [bold]uv sync --extra web[/bold])"
57
+ )
58
+ # Also write to stdout so CliRunner captures it in res.output during tests.
59
+ console.print(
60
+ "[red]Web server support is not installed.[/red]\n"
61
+ "Install it with: [bold]pip install 'seam-mcp\\[web]'[/bold]"
62
+ )
63
+ raise typer.Exit(code=1)
64
+
65
+
66
+ def serve_command(
67
+ path: str = typer.Argument(".", help="Project root to serve (default: current directory)"),
68
+ host: str = typer.Option("127.0.0.1", "--host", help="Host to bind (default: 127.0.0.1)"),
69
+ port: int = typer.Option(7420, "--port", help="Port to listen on (default: 7420)"),
70
+ no_open: bool = typer.Option(
71
+ False,
72
+ "--no-open",
73
+ help="Do not open the browser automatically after starting.",
74
+ ),
75
+ ) -> None:
76
+ """Start the Seam Explorer web server (read-only, 127.0.0.1 only).
77
+
78
+ Opens a local browser window pointing at the visual graph explorer unless
79
+ --no-open is given. The server exposes the /api/* endpoints backed by the
80
+ Seam index at .seam/seam.db and serves the built SPA at '/'.
81
+
82
+ Requires the [web] extra: pip install 'seam-mcp[web]'
83
+ """
84
+ # Resolve paths the same way every other read command does (mirrors read.py).
85
+ project_root = Path(path).resolve()
86
+ db_path = config.get_db_path(project_root)
87
+
88
+ # Guard: no index → emit NO_INDEX and exit 1 before importing fastapi/uvicorn.
89
+ # WHY check the index BEFORE the lazy import: a missing index is a user error that
90
+ # should be reported clearly regardless of whether [web] is installed. We report it
91
+ # first (cheap path) so the user gets the right fix hint ("seam init") without also
92
+ # needing to install the [web] extra first.
93
+ if not db_path.exists():
94
+ console.print(
95
+ "[red]No index found.[/red] Run [bold]seam init[/bold] first to create the index."
96
+ )
97
+ raise typer.Exit(code=1)
98
+
99
+ # Security guard: warn loudly on a non-loopback bind. The server is READ-ONLY but
100
+ # has NO auth, so binding to a routable address exposes the indexed source code to
101
+ # anyone on the network. ADR-010 mandates 127.0.0.1-only; we warn rather than hard-
102
+ # reject so intentional container/devcontainer use (where 0.0.0.0 is required) stays
103
+ # possible — but the user is told explicitly what they are exposing.
104
+ if host not in ("127.0.0.1", "localhost", "::1"):
105
+ err_console.print(
106
+ f"[bold yellow]⚠ Warning:[/bold yellow] binding to [bold]{host}[/bold] exposes "
107
+ "your indexed source code on the network with NO authentication.\n"
108
+ "[dim]Seam Explorer is designed for 127.0.0.1 only. Use a non-loopback host "
109
+ "only on a trusted network you control.[/dim]"
110
+ )
111
+
112
+ # Lazy-import the web factory — will exit 1 with a hint if [web] not installed.
113
+ # WHY after index check: if the index is missing we surface that error first (it's
114
+ # cheaper and does not depend on [web]), then require the extra only for the actual run.
115
+ create_web_app = _load_web_app_factory()
116
+
117
+ # Build the FastAPI app.
118
+ web_app = create_web_app(db_path=db_path, root=project_root)
119
+
120
+ # Open the browser BEFORE blocking on uvicorn so the window appears promptly.
121
+ # WHY before uvicorn: uvicorn.run() blocks; the browser.open() would never execute
122
+ # if placed after it. The tiny race (browser opens before the server is ready) is
123
+ # acceptable — browsers retry on "connection refused" within ~1 s.
124
+ url = f"http://{host}:{port}"
125
+ if not no_open:
126
+ webbrowser.open(url)
127
+
128
+ console.print(
129
+ f"[bold green]Seam Explorer[/bold green] running at [cyan]{url}[/cyan]\n"
130
+ "[dim]Press Ctrl+C to stop.[/dim]"
131
+ )
132
+
133
+ # Lazy-import uvicorn — also guarded by _load_web_app_factory above (fastapi is a
134
+ # transitive dep of uvicorn in the [web] extra), but explicit here for clarity.
135
+ try:
136
+ import uvicorn # noqa: PLC0415
137
+ except ImportError:
138
+ err_console.print(
139
+ "[red]uvicorn is not installed.[/red] "
140
+ "Install it with: [bold]pip install 'seam-mcp[web]'[/bold]"
141
+ )
142
+ raise typer.Exit(code=1)
143
+
144
+ # Run uvicorn programmatically (blocks until Ctrl+C / SIGTERM).
145
+ uvicorn.run(web_app, host=host, port=port, log_level="warning")