Navegador

feat: expand CLI with function/class/query commands, clean JSON output for agent use

lmata 2026-03-22 21:21 trunk
Commit e72710e6001cfc982fc8104fd9fa18ce5e5326cfe2188b1a7b7109f8db6c994e
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -1,7 +1,10 @@
11
"""
22
Navegador CLI — ingest repos, load context, serve MCP.
3
+
4
+All commands support --format json for clean stdout output suitable for
5
+piping to agents or other tools without MCP overhead.
36
"""
47
58
import asyncio
69
import logging
710
@@ -9,15 +12,34 @@
912
from rich.console import Console
1013
from rich.table import Table
1114
1215
console = Console()
1316
17
+DB_OPTION = click.option(
18
+ "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path."
19
+)
20
+FMT_OPTION = click.option(
21
+ "--format", "fmt",
22
+ type=click.Choice(["markdown", "json"]),
23
+ default="markdown",
24
+ show_default=True,
25
+ help="Output format. Use json for agent/pipe consumption.",
26
+)
27
+
1428
1529
def _get_store(db: str):
1630
from navegador.graph import GraphStore
1731
return GraphStore.sqlite(db)
1832
33
+
34
+def _emit(text: str, fmt: str) -> None:
35
+ """Print text — raw to stdout for json, rich for markdown."""
36
+ if fmt == "json":
37
+ click.echo(text)
38
+ else:
39
+ console.print(text)
40
+
1941
2042
@click.group()
2143
@click.version_option(package_name="navegador")
2244
def main():
2345
"""Navegador — AST + knowledge graph context engine for AI coding agents."""
@@ -24,56 +46,99 @@
2446
logging.basicConfig(level=logging.WARNING)
2547
2648
2749
@main.command()
2850
@click.argument("repo_path", type=click.Path(exists=True))
29
-@click.option("--db", default=".navegador/graph.db", show_default=True, help="Graph DB path.")
51
+@DB_OPTION
3052
@click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
31
-def ingest(repo_path: str, db: str, clear: bool):
53
+@click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
54
+def ingest(repo_path: str, db: str, clear: bool, as_json: bool):
3255
"""Ingest a repository into the navegador graph."""
56
+ import json
57
+
3358
from navegador.ingestion import RepoIngester
3459
3560
store = _get_store(db)
3661
ingester = RepoIngester(store)
3762
38
- with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
63
+ if as_json:
3964
stats = ingester.ingest(repo_path, clear=clear)
40
-
41
- table = Table(title="Ingestion complete", show_header=True)
42
- table.add_column("Metric", style="cyan")
43
- table.add_column("Count", justify="right", style="green")
44
- for k, v in stats.items():
45
- table.add_row(k.capitalize(), str(v))
46
- console.print(table)
65
+ click.echo(json.dumps(stats, indent=2))
66
+ else:
67
+ with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
68
+ stats = ingester.ingest(repo_path, clear=clear)
69
+ table = Table(title="Ingestion complete", show_header=True)
70
+ table.add_column("Metric", style="cyan")
71
+ table.add_column("Count", justify="right", style="green")
72
+ for k, v in stats.items():
73
+ table.add_row(k.capitalize(), str(v))
74
+ console.print(table)
4775
4876
4977
@main.command()
5078
@click.argument("file_path")
51
-@click.option("--db", default=".navegador/graph.db", show_default=True)
52
-@click.option("--format", "fmt", type=click.Choice(["markdown", "json"]), default="markdown")
79
+@DB_OPTION
80
+@FMT_OPTION
5381
def context(file_path: str, db: str, fmt: str):
54
- """Load and print context for a file."""
82
+ """Load context for a file — all symbols and their relationships."""
83
+ from navegador.context import ContextLoader
84
+
85
+ loader = ContextLoader(_get_store(db))
86
+ bundle = loader.load_file(file_path)
87
+ _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
88
+
89
+
90
+@main.command()
91
+@click.argument("name")
92
+@click.option("--file", "file_path", default="", help="File path to narrow the search.")
93
+@click.option("--depth", default=2, show_default=True, help="Call graph traversal depth.")
94
+@DB_OPTION
95
+@FMT_OPTION
96
+def function(name: str, file_path: str, db: str, depth: int, fmt: str):
97
+ """Load context for a function — callers, callees, signature."""
98
+ from navegador.context import ContextLoader
99
+
100
+ loader = ContextLoader(_get_store(db))
101
+ bundle = loader.load_function(name, file_path=file_path, depth=depth)
102
+ _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
103
+
104
+
105
+@main.command("class")
106
+@click.argument("name")
107
+@click.option("--file", "file_path", default="", help="File path to narrow the search.")
108
+@DB_OPTION
109
+@FMT_OPTION
110
+def class_(name: str, file_path: str, db: str, fmt: str):
111
+ """Load context for a class — methods, inheritance, subclasses."""
55112
from navegador.context import ContextLoader
56113
57
- store = _get_store(db)
58
- loader = ContextLoader(store)
59
- bundle = loader.load_file(file_path)
60
- output = bundle.to_markdown() if fmt == "markdown" else bundle.to_json()
61
- console.print(output)
114
+ loader = ContextLoader(_get_store(db))
115
+ bundle = loader.load_class(name, file_path=file_path)
116
+ _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
62117
63118
64119
@main.command()
65120
@click.argument("query")
66
-@click.option("--db", default=".navegador/graph.db", show_default=True)
121
+@DB_OPTION
67122
@click.option("--limit", default=20, show_default=True)
68
-def search(query: str, db: str, limit: int):
69
- """Search for symbols (functions, classes) by name."""
123
+@FMT_OPTION
124
+def search(query: str, db: str, limit: int, fmt: str):
125
+ """Search for symbols (functions, classes, methods) by name."""
126
+ import json
127
+
70128
from navegador.context import ContextLoader
71129
72
- store = _get_store(db)
73
- loader = ContextLoader(store)
130
+ loader = ContextLoader(_get_store(db))
74131
results = loader.search(query, limit=limit)
132
+
133
+ if fmt == "json":
134
+ click.echo(json.dumps([
135
+ {"type": r.type, "name": r.name, "file_path": r.file_path,
136
+ "line_start": r.line_start, "docstring": r.docstring}
137
+ for r in results
138
+ ], indent=2))
139
+ return
75140
76141
if not results:
77142
console.print("[yellow]No results.[/yellow]")
78143
return
79144
@@ -86,38 +151,54 @@
86151
table.add_row(r.type, r.name, r.file_path, str(r.line_start or ""))
87152
console.print(table)
88153
89154
90155
@main.command()
91
-@click.option("--db", default=".navegador/graph.db", show_default=True)
92
-def stats(db: str):
156
+@click.argument("cypher")
157
+@DB_OPTION
158
+def query(cypher: str, db: str):
159
+ """Run a raw Cypher query and print results as JSON."""
160
+ import json
161
+
162
+ store = _get_store(db)
163
+ result = store.query(cypher)
164
+ rows = result.result_set or []
165
+ click.echo(json.dumps(rows, default=str, indent=2))
166
+
167
+
168
+@main.command()
169
+@DB_OPTION
170
+@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
171
+def stats(db: str, as_json: bool):
93172
"""Show graph statistics."""
173
+ import json
174
+
94175
store = _get_store(db)
95
- table = Table(title="Graph stats", show_header=True)
96
- table.add_column("Metric", style="cyan")
97
- table.add_column("Count", justify="right", style="green")
98
- table.add_row("Nodes", str(store.node_count()))
99
- table.add_row("Edges", str(store.edge_count()))
100
- console.print(table)
176
+ data = {"nodes": store.node_count(), "edges": store.edge_count()}
177
+
178
+ if as_json:
179
+ click.echo(json.dumps(data, indent=2))
180
+ else:
181
+ table = Table(title="Graph stats", show_header=True)
182
+ table.add_column("Metric", style="cyan")
183
+ table.add_column("Count", justify="right", style="green")
184
+ for k, v in data.items():
185
+ table.add_row(k.capitalize(), str(v))
186
+ console.print(table)
101187
102188
103189
@main.command()
104
-@click.option("--db", default=".navegador/graph.db", show_default=True)
105
-@click.option("--host", default="127.0.0.1", show_default=True)
106
-@click.option("--port", default=8765, show_default=True)
107
-def mcp(db: str, host: str, port: int):
108
- """Start the MCP server for AI agent integration."""
190
+@DB_OPTION
191
+def mcp(db: str):
192
+ """Start the MCP server for AI agent integration (stdio)."""
109193
from mcp.server.stdio import stdio_server # type: ignore[import]
110194
111195
from navegador.mcp import create_mcp_server
112196
113
- def store_factory():
114
- return _get_store(db)
115
-
116
- server = create_mcp_server(store_factory)
197
+ server = create_mcp_server(lambda: _get_store(db))
117198
console.print("[green]Navegador MCP server running[/green] (stdio)")
118199
119200
async def _run():
120201
async with stdio_server() as (read_stream, write_stream):
121202
await server.run(read_stream, write_stream, server.create_initialization_options())
122203
123204
asyncio.run(_run())
124205
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -1,7 +1,10 @@
1 """
2 Navegador CLI — ingest repos, load context, serve MCP.
 
 
 
3 """
4
5 import asyncio
6 import logging
7
@@ -9,15 +12,34 @@
9 from rich.console import Console
10 from rich.table import Table
11
12 console = Console()
13
 
 
 
 
 
 
 
 
 
 
 
14
15 def _get_store(db: str):
16 from navegador.graph import GraphStore
17 return GraphStore.sqlite(db)
18
 
 
 
 
 
 
 
 
19
20 @click.group()
21 @click.version_option(package_name="navegador")
22 def main():
23 """Navegador — AST + knowledge graph context engine for AI coding agents."""
@@ -24,56 +46,99 @@
24 logging.basicConfig(level=logging.WARNING)
25
26
27 @main.command()
28 @click.argument("repo_path", type=click.Path(exists=True))
29 @click.option("--db", default=".navegador/graph.db", show_default=True, help="Graph DB path.")
30 @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
31 def ingest(repo_path: str, db: str, clear: bool):
 
32 """Ingest a repository into the navegador graph."""
 
 
33 from navegador.ingestion import RepoIngester
34
35 store = _get_store(db)
36 ingester = RepoIngester(store)
37
38 with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
39 stats = ingester.ingest(repo_path, clear=clear)
40
41 table = Table(title="Ingestion complete", show_header=True)
42 table.add_column("Metric", style="cyan")
43 table.add_column("Count", justify="right", style="green")
44 for k, v in stats.items():
45 table.add_row(k.capitalize(), str(v))
46 console.print(table)
 
 
 
47
48
49 @main.command()
50 @click.argument("file_path")
51 @click.option("--db", default=".navegador/graph.db", show_default=True)
52 @click.option("--format", "fmt", type=click.Choice(["markdown", "json"]), default="markdown")
53 def context(file_path: str, db: str, fmt: str):
54 """Load and print context for a file."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55 from navegador.context import ContextLoader
56
57 store = _get_store(db)
58 loader = ContextLoader(store)
59 bundle = loader.load_file(file_path)
60 output = bundle.to_markdown() if fmt == "markdown" else bundle.to_json()
61 console.print(output)
62
63
64 @main.command()
65 @click.argument("query")
66 @click.option("--db", default=".navegador/graph.db", show_default=True)
67 @click.option("--limit", default=20, show_default=True)
68 def search(query: str, db: str, limit: int):
69 """Search for symbols (functions, classes) by name."""
 
 
 
70 from navegador.context import ContextLoader
71
72 store = _get_store(db)
73 loader = ContextLoader(store)
74 results = loader.search(query, limit=limit)
 
 
 
 
 
 
 
 
75
76 if not results:
77 console.print("[yellow]No results.[/yellow]")
78 return
79
@@ -86,38 +151,54 @@
86 table.add_row(r.type, r.name, r.file_path, str(r.line_start or ""))
87 console.print(table)
88
89
90 @main.command()
91 @click.option("--db", default=".navegador/graph.db", show_default=True)
92 def stats(db: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93 """Show graph statistics."""
 
 
94 store = _get_store(db)
95 table = Table(title="Graph stats", show_header=True)
96 table.add_column("Metric", style="cyan")
97 table.add_column("Count", justify="right", style="green")
98 table.add_row("Nodes", str(store.node_count()))
99 table.add_row("Edges", str(store.edge_count()))
100 console.print(table)
 
 
 
 
 
101
102
103 @main.command()
104 @click.option("--db", default=".navegador/graph.db", show_default=True)
105 @click.option("--host", default="127.0.0.1", show_default=True)
106 @click.option("--port", default=8765, show_default=True)
107 def mcp(db: str, host: str, port: int):
108 """Start the MCP server for AI agent integration."""
109 from mcp.server.stdio import stdio_server # type: ignore[import]
110
111 from navegador.mcp import create_mcp_server
112
113 def store_factory():
114 return _get_store(db)
115
116 server = create_mcp_server(store_factory)
117 console.print("[green]Navegador MCP server running[/green] (stdio)")
118
119 async def _run():
120 async with stdio_server() as (read_stream, write_stream):
121 await server.run(read_stream, write_stream, server.create_initialization_options())
122
123 asyncio.run(_run())
124
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -1,7 +1,10 @@
1 """
2 Navegador CLI — ingest repos, load context, serve MCP.
3
4 All commands support --format json for clean stdout output suitable for
5 piping to agents or other tools without MCP overhead.
6 """
7
8 import asyncio
9 import logging
10
@@ -9,15 +12,34 @@
12 from rich.console import Console
13 from rich.table import Table
14
15 console = Console()
16
17 DB_OPTION = click.option(
18 "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path."
19 )
20 FMT_OPTION = click.option(
21 "--format", "fmt",
22 type=click.Choice(["markdown", "json"]),
23 default="markdown",
24 show_default=True,
25 help="Output format. Use json for agent/pipe consumption.",
26 )
27
28
29 def _get_store(db: str):
30 from navegador.graph import GraphStore
31 return GraphStore.sqlite(db)
32
33
34 def _emit(text: str, fmt: str) -> None:
35 """Print text — raw to stdout for json, rich for markdown."""
36 if fmt == "json":
37 click.echo(text)
38 else:
39 console.print(text)
40
41
42 @click.group()
43 @click.version_option(package_name="navegador")
44 def main():
45 """Navegador — AST + knowledge graph context engine for AI coding agents."""
@@ -24,56 +46,99 @@
46 logging.basicConfig(level=logging.WARNING)
47
48
49 @main.command()
50 @click.argument("repo_path", type=click.Path(exists=True))
51 @DB_OPTION
52 @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
53 @click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
54 def ingest(repo_path: str, db: str, clear: bool, as_json: bool):
55 """Ingest a repository into the navegador graph."""
56 import json
57
58 from navegador.ingestion import RepoIngester
59
60 store = _get_store(db)
61 ingester = RepoIngester(store)
62
63 if as_json:
64 stats = ingester.ingest(repo_path, clear=clear)
65 click.echo(json.dumps(stats, indent=2))
66 else:
67 with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
68 stats = ingester.ingest(repo_path, clear=clear)
69 table = Table(title="Ingestion complete", show_header=True)
70 table.add_column("Metric", style="cyan")
71 table.add_column("Count", justify="right", style="green")
72 for k, v in stats.items():
73 table.add_row(k.capitalize(), str(v))
74 console.print(table)
75
76
77 @main.command()
78 @click.argument("file_path")
79 @DB_OPTION
80 @FMT_OPTION
81 def context(file_path: str, db: str, fmt: str):
82 """Load context for a file — all symbols and their relationships."""
83 from navegador.context import ContextLoader
84
85 loader = ContextLoader(_get_store(db))
86 bundle = loader.load_file(file_path)
87 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
88
89
90 @main.command()
91 @click.argument("name")
92 @click.option("--file", "file_path", default="", help="File path to narrow the search.")
93 @click.option("--depth", default=2, show_default=True, help="Call graph traversal depth.")
94 @DB_OPTION
95 @FMT_OPTION
96 def function(name: str, file_path: str, db: str, depth: int, fmt: str):
97 """Load context for a function — callers, callees, signature."""
98 from navegador.context import ContextLoader
99
100 loader = ContextLoader(_get_store(db))
101 bundle = loader.load_function(name, file_path=file_path, depth=depth)
102 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
103
104
105 @main.command("class")
106 @click.argument("name")
107 @click.option("--file", "file_path", default="", help="File path to narrow the search.")
108 @DB_OPTION
109 @FMT_OPTION
110 def class_(name: str, file_path: str, db: str, fmt: str):
111 """Load context for a class — methods, inheritance, subclasses."""
112 from navegador.context import ContextLoader
113
114 loader = ContextLoader(_get_store(db))
115 bundle = loader.load_class(name, file_path=file_path)
116 _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
 
 
117
118
119 @main.command()
120 @click.argument("query")
121 @DB_OPTION
122 @click.option("--limit", default=20, show_default=True)
123 @FMT_OPTION
124 def search(query: str, db: str, limit: int, fmt: str):
125 """Search for symbols (functions, classes, methods) by name."""
126 import json
127
128 from navegador.context import ContextLoader
129
130 loader = ContextLoader(_get_store(db))
 
131 results = loader.search(query, limit=limit)
132
133 if fmt == "json":
134 click.echo(json.dumps([
135 {"type": r.type, "name": r.name, "file_path": r.file_path,
136 "line_start": r.line_start, "docstring": r.docstring}
137 for r in results
138 ], indent=2))
139 return
140
141 if not results:
142 console.print("[yellow]No results.[/yellow]")
143 return
144
@@ -86,38 +151,54 @@
151 table.add_row(r.type, r.name, r.file_path, str(r.line_start or ""))
152 console.print(table)
153
154
155 @main.command()
156 @click.argument("cypher")
157 @DB_OPTION
158 def query(cypher: str, db: str):
159 """Run a raw Cypher query and print results as JSON."""
160 import json
161
162 store = _get_store(db)
163 result = store.query(cypher)
164 rows = result.result_set or []
165 click.echo(json.dumps(rows, default=str, indent=2))
166
167
168 @main.command()
169 @DB_OPTION
170 @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
171 def stats(db: str, as_json: bool):
172 """Show graph statistics."""
173 import json
174
175 store = _get_store(db)
176 data = {"nodes": store.node_count(), "edges": store.edge_count()}
177
178 if as_json:
179 click.echo(json.dumps(data, indent=2))
180 else:
181 table = Table(title="Graph stats", show_header=True)
182 table.add_column("Metric", style="cyan")
183 table.add_column("Count", justify="right", style="green")
184 for k, v in data.items():
185 table.add_row(k.capitalize(), str(v))
186 console.print(table)
187
188
189 @main.command()
190 @DB_OPTION
191 def mcp(db: str):
192 """Start the MCP server for AI agent integration (stdio)."""
 
 
193 from mcp.server.stdio import stdio_server # type: ignore[import]
194
195 from navegador.mcp import create_mcp_server
196
197 server = create_mcp_server(lambda: _get_store(db))
 
 
 
198 console.print("[green]Navegador MCP server running[/green] (stdio)")
199
200 async def _run():
201 async with stdio_server() as (read_stream, write_stream):
202 await server.run(read_stream, write_stream, server.create_initialization_options())
203
204 asyncio.run(_run())
205
--- navegador/context/loader.py
+++ navegador/context/loader.py
@@ -114,11 +114,11 @@
114114
target=target,
115115
nodes=nodes,
116116
metadata={"depth": depth, "query": "file_contents"},
117117
)
118118
119
- def load_function(self, name: str, file_path: str, depth: int = 2) -> ContextBundle:
119
+ def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle:
120120
"""Load context for a function — its callers and callees."""
121121
target = ContextNode(type="Function", name=name, file_path=file_path)
122122
nodes: list[ContextNode] = []
123123
edges: list[dict[str, str]] = []
124124
@@ -137,11 +137,11 @@
137137
edges.append({"from": row[1], "type": "CALLS", "to": name})
138138
139139
return ContextBundle(target=target, nodes=nodes, edges=edges,
140140
metadata={"depth": depth, "query": "function_context"})
141141
142
- def load_class(self, name: str, file_path: str) -> ContextBundle:
142
+ def load_class(self, name: str, file_path: str = "") -> ContextBundle:
143143
"""Load context for a class — its methods, parent classes, and subclasses."""
144144
target = ContextNode(type="Class", name=name, file_path=file_path)
145145
nodes: list[ContextNode] = []
146146
edges: list[dict[str, str]] = []
147147
148148
--- navegador/context/loader.py
+++ navegador/context/loader.py
@@ -114,11 +114,11 @@
114 target=target,
115 nodes=nodes,
116 metadata={"depth": depth, "query": "file_contents"},
117 )
118
119 def load_function(self, name: str, file_path: str, depth: int = 2) -> ContextBundle:
120 """Load context for a function — its callers and callees."""
121 target = ContextNode(type="Function", name=name, file_path=file_path)
122 nodes: list[ContextNode] = []
123 edges: list[dict[str, str]] = []
124
@@ -137,11 +137,11 @@
137 edges.append({"from": row[1], "type": "CALLS", "to": name})
138
139 return ContextBundle(target=target, nodes=nodes, edges=edges,
140 metadata={"depth": depth, "query": "function_context"})
141
142 def load_class(self, name: str, file_path: str) -> ContextBundle:
143 """Load context for a class — its methods, parent classes, and subclasses."""
144 target = ContextNode(type="Class", name=name, file_path=file_path)
145 nodes: list[ContextNode] = []
146 edges: list[dict[str, str]] = []
147
148
--- navegador/context/loader.py
+++ navegador/context/loader.py
@@ -114,11 +114,11 @@
114 target=target,
115 nodes=nodes,
116 metadata={"depth": depth, "query": "file_contents"},
117 )
118
119 def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle:
120 """Load context for a function — its callers and callees."""
121 target = ContextNode(type="Function", name=name, file_path=file_path)
122 nodes: list[ContextNode] = []
123 edges: list[dict[str, str]] = []
124
@@ -137,11 +137,11 @@
137 edges.append({"from": row[1], "type": "CALLS", "to": name})
138
139 return ContextBundle(target=target, nodes=nodes, edges=edges,
140 metadata={"depth": depth, "query": "function_context"})
141
142 def load_class(self, name: str, file_path: str = "") -> ContextBundle:
143 """Load context for a class — its methods, parent classes, and subclasses."""
144 target = ContextNode(type="Class", name=name, file_path=file_path)
145 nodes: list[ContextNode] = []
146 edges: list[dict[str, str]] = []
147
148

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button