eulerian 0.1.0__tar.gz

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.
eulerian-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Curtis Bangert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: eulerian
3
+ Version: 0.1.0
4
+ Summary: Standalone real-time code index — live local directories and registered git repos
5
+ Keywords: code-navigation,mcp,tree-sitter,git-worktree,code-index,ai-agent
6
+ Author: Curtis Bangert
7
+ Author-email: Curtis Bangert <codecae@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Utilities
15
+ Classifier: Topic :: Text Editors :: Integrated Development Environments (IDE)
16
+ Requires-Dist: fastmcp>=3.2.0
17
+ Requires-Dist: typer>=0.15.0
18
+ Requires-Dist: tree-sitter>=0.21
19
+ Requires-Dist: tree-sitter-python>=0.21
20
+ Requires-Dist: rapidfuzz>=3.0.0
21
+ Requires-Dist: watchfiles>=0.24.0
22
+ Requires-Dist: rich>=14.0.0
23
+ Requires-Python: >=3.13
24
+ Description-Content-Type: text/markdown
25
+
26
+ # eulerian
27
+
28
+ Real-time code navigation for AI agents and editors. `eulerian` keeps a live
29
+ symbol index — files, classes, functions, methods, constants, with line
30
+ ranges — so an agent can jump straight to a definition instead of grepping
31
+ or reading whole files.
32
+
33
+ Two ways to point it at code:
34
+
35
+ - **Local mode** — watch a real working directory. A filesystem watcher
36
+ re-parses files the moment they're saved; the index never goes stale and
37
+ there's no explicit re-index step.
38
+ - **Repo mode** — register a git remote, check out a `(user, repo, ref)`
39
+ worktree, and browse it without touching your actual working tree.
40
+ Freshness is checked against the remote on each call instead of pushed
41
+ live, since nothing is editing the worktree directly.
42
+
43
+ Both modes share one in-memory symbol index and the same tree-sitter parser.
44
+ Python only for now (multi-language grammars are straightforward to add —
45
+ see `parser.py`).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ uv tool install eulerian
51
+ # or
52
+ pip install eulerian
53
+ ```
54
+
55
+ No other runtime dependencies beyond `tree-sitter`, `tree-sitter-python`,
56
+ `rapidfuzz`, `watchfiles`, `fastmcp`, and `typer` — all pulled in
57
+ automatically.
58
+
59
+ ## CLI
60
+
61
+ ```bash
62
+ # Local mode: one-shot queries against a directory, no persistent watcher
63
+ eulerian status [PATH]
64
+ eulerian outline QUERY [PATH] [-k N]
65
+ eulerian find SYMBOL [PATH]
66
+
67
+ # Local mode: live watch + MCP server (stdio by default, for an MCP client)
68
+ eulerian watch [PATH] [--http --host HOST --port PORT]
69
+
70
+ # Repo mode: register, checkout, browse
71
+ eulerian repo add URL
72
+ eulerian repo list
73
+ eulerian repo fetch REPO
74
+ eulerian repo checkout REPO REF [--user NAME]
75
+ eulerian repo outline REPO REF QUERY [--user NAME] [-k N]
76
+ ```
77
+
78
+ ## MCP server
79
+
80
+ `eulerian watch [PATH]` starts a FastMCP server (stdio or `--http`) exposing:
81
+
82
+ **Local mode** — `watch(path)`, `unwatch(path)`, `outline(query, paths, root, k, format)`,
83
+ `find(symbol, root)`, `read(path, start, end, root)`, `status(root)`.
84
+
85
+ **Repo mode** — `repo_add(url)`, `repo_fetch(repo)`, `repo_list()`, `repo_pubkey()`,
86
+ `checkout(repo, ref, user)`, `checkout_close(repo, ref, user)`, `git_log(repo, ref, user, n)`,
87
+ `repo_outline(repo, ref, query, paths, user, k, format, reset_head)`,
88
+ `repo_read(repo, ref, path, user, start, end, segments, reset_head)`.
89
+
90
+ Point any MCP-capable client (Claude Code, etc.) at `eulerian watch <path>`
91
+ to get instant, line-precise code navigation without burning tool calls on
92
+ `grep`/`read` round-trips.
93
+
94
+ ## Repo-mode configuration
95
+
96
+ Bare clones and worktrees live under `~/.eulerian/` by default:
97
+
98
+ | Env var | Default | Purpose |
99
+ |---|---|---|
100
+ | `EULERIAN_GIT_REPOS_PATH` | `~/.eulerian/repos/` | Bare clone + worktree storage |
101
+ | `EULERIAN_GIT_SSH_KEY` | `~/.eulerian/ssh/id_ed25519` | Deploy key for private remotes |
102
+ | `EULERIAN_GIT_WORKTREE_MAX_IDLE_SEC` | `3600` | Idle worktree eviction threshold |
103
+
104
+ ## Architecture
105
+
106
+ ```
107
+ parser.py tree-sitter Python parser -> Symbol / CallEdge / ImportEdge
108
+ index.py in-memory Index, keyed by an opaque handle, rapidfuzz search
109
+ sources/local.py watchfiles watcher -> handle = resolved root Path
110
+ sources/repo.py bare clone + worktree lifecycle -> handle = (user, repo, ref)
111
+ mcp/ FastMCP server exposing both modes as tools
112
+ cli.py typer CLI
113
+ ```
114
+
115
+ `Index` doesn't care what a handle is — local mode and repo mode just pick
116
+ different shapes for it. Both feed the same `outline`/`find` query surface.
@@ -0,0 +1,91 @@
1
+ # eulerian
2
+
3
+ Real-time code navigation for AI agents and editors. `eulerian` keeps a live
4
+ symbol index — files, classes, functions, methods, constants, with line
5
+ ranges — so an agent can jump straight to a definition instead of grepping
6
+ or reading whole files.
7
+
8
+ Two ways to point it at code:
9
+
10
+ - **Local mode** — watch a real working directory. A filesystem watcher
11
+ re-parses files the moment they're saved; the index never goes stale and
12
+ there's no explicit re-index step.
13
+ - **Repo mode** — register a git remote, check out a `(user, repo, ref)`
14
+ worktree, and browse it without touching your actual working tree.
15
+ Freshness is checked against the remote on each call instead of pushed
16
+ live, since nothing is editing the worktree directly.
17
+
18
+ Both modes share one in-memory symbol index and the same tree-sitter parser.
19
+ Python only for now (multi-language grammars are straightforward to add —
20
+ see `parser.py`).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ uv tool install eulerian
26
+ # or
27
+ pip install eulerian
28
+ ```
29
+
30
+ No other runtime dependencies beyond `tree-sitter`, `tree-sitter-python`,
31
+ `rapidfuzz`, `watchfiles`, `fastmcp`, and `typer` — all pulled in
32
+ automatically.
33
+
34
+ ## CLI
35
+
36
+ ```bash
37
+ # Local mode: one-shot queries against a directory, no persistent watcher
38
+ eulerian status [PATH]
39
+ eulerian outline QUERY [PATH] [-k N]
40
+ eulerian find SYMBOL [PATH]
41
+
42
+ # Local mode: live watch + MCP server (stdio by default, for an MCP client)
43
+ eulerian watch [PATH] [--http --host HOST --port PORT]
44
+
45
+ # Repo mode: register, checkout, browse
46
+ eulerian repo add URL
47
+ eulerian repo list
48
+ eulerian repo fetch REPO
49
+ eulerian repo checkout REPO REF [--user NAME]
50
+ eulerian repo outline REPO REF QUERY [--user NAME] [-k N]
51
+ ```
52
+
53
+ ## MCP server
54
+
55
+ `eulerian watch [PATH]` starts a FastMCP server (stdio or `--http`) exposing:
56
+
57
+ **Local mode** — `watch(path)`, `unwatch(path)`, `outline(query, paths, root, k, format)`,
58
+ `find(symbol, root)`, `read(path, start, end, root)`, `status(root)`.
59
+
60
+ **Repo mode** — `repo_add(url)`, `repo_fetch(repo)`, `repo_list()`, `repo_pubkey()`,
61
+ `checkout(repo, ref, user)`, `checkout_close(repo, ref, user)`, `git_log(repo, ref, user, n)`,
62
+ `repo_outline(repo, ref, query, paths, user, k, format, reset_head)`,
63
+ `repo_read(repo, ref, path, user, start, end, segments, reset_head)`.
64
+
65
+ Point any MCP-capable client (Claude Code, etc.) at `eulerian watch <path>`
66
+ to get instant, line-precise code navigation without burning tool calls on
67
+ `grep`/`read` round-trips.
68
+
69
+ ## Repo-mode configuration
70
+
71
+ Bare clones and worktrees live under `~/.eulerian/` by default:
72
+
73
+ | Env var | Default | Purpose |
74
+ |---|---|---|
75
+ | `EULERIAN_GIT_REPOS_PATH` | `~/.eulerian/repos/` | Bare clone + worktree storage |
76
+ | `EULERIAN_GIT_SSH_KEY` | `~/.eulerian/ssh/id_ed25519` | Deploy key for private remotes |
77
+ | `EULERIAN_GIT_WORKTREE_MAX_IDLE_SEC` | `3600` | Idle worktree eviction threshold |
78
+
79
+ ## Architecture
80
+
81
+ ```
82
+ parser.py tree-sitter Python parser -> Symbol / CallEdge / ImportEdge
83
+ index.py in-memory Index, keyed by an opaque handle, rapidfuzz search
84
+ sources/local.py watchfiles watcher -> handle = resolved root Path
85
+ sources/repo.py bare clone + worktree lifecycle -> handle = (user, repo, ref)
86
+ mcp/ FastMCP server exposing both modes as tools
87
+ cli.py typer CLI
88
+ ```
89
+
90
+ `Index` doesn't care what a handle is — local mode and repo mode just pick
91
+ different shapes for it. Both feed the same `outline`/`find` query surface.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "eulerian"
3
+ version = "0.1.0"
4
+ description = "Standalone real-time code index — live local directories and registered git repos"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Curtis Bangert", email = "codecae@gmail.com" }
10
+ ]
11
+ keywords = ["code-navigation", "mcp", "tree-sitter", "git-worktree", "code-index", "ai-agent"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Topic :: Utilities",
18
+ "Topic :: Text Editors :: Integrated Development Environments (IDE)",
19
+ ]
20
+ requires-python = ">=3.13"
21
+ dependencies = [
22
+ "fastmcp>=3.2.0",
23
+ "typer>=0.15.0",
24
+ "tree-sitter>=0.21",
25
+ "tree-sitter-python>=0.21",
26
+ "rapidfuzz>=3.0.0",
27
+ "watchfiles>=0.24.0",
28
+ "rich>=14.0.0",
29
+ ]
30
+
31
+ [project.scripts]
32
+ eulerian = "eulerian.cli:app"
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.11.0,<0.12.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.commitizen]
39
+ name = "cz_conventional_commits"
40
+ version_scheme = "semver"
41
+ version_provider = "pep621"
42
+ tag_format = "eulerian-v$version"
43
+ allowed_scopes = ["eulerian"]
@@ -0,0 +1 @@
1
+ """eulerian — standalone real-time code index for local directories and git repos."""
@@ -0,0 +1,197 @@
1
+ """eulerian CLI — watch, outline, find, status, repo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ app = typer.Typer(
12
+ name="eulerian",
13
+ help="Real-time code index for local directories and git repos.",
14
+ no_args_is_help=True,
15
+ )
16
+ console = Console()
17
+
18
+
19
+ @app.command()
20
+ def watch(
21
+ path: Path = typer.Argument(
22
+ Path("."), help="Directory to watch (default: current directory)"
23
+ ),
24
+ http: bool = typer.Option(
25
+ False, "--http", help="Serve MCP over HTTP instead of stdio"
26
+ ),
27
+ host: str = typer.Option("0.0.0.0", "--host", help="Bind host (http mode only)"),
28
+ port: int = typer.Option(8090, "--port", help="Bind port (http mode only)"),
29
+ ) -> None:
30
+ """Start watching a directory and serve the MCP server (stdio or http).
31
+
32
+ Performs a full scan immediately, then keeps the index live via a
33
+ filesystem watcher for the lifetime of this process.
34
+ """
35
+ import asyncio
36
+
37
+ from .sources import local as _local
38
+ from .state import index
39
+ from .mcp.server import server as mcp_server
40
+
41
+ root = path.expanduser().resolve()
42
+ if not root.is_dir():
43
+ console.print(f"[red]Not a directory:[/red] {root}")
44
+ raise typer.Exit(1)
45
+
46
+ watcher = _local.watch_root(index, root)
47
+ console.print(
48
+ f"[green]eulerian[/green] watching {watcher.root} ({watcher.symbol_count} symbols)"
49
+ )
50
+
51
+ if http:
52
+ console.print(f" MCP (http) listening on {host}:{port}")
53
+ asyncio.run(mcp_server.run_http_async(host=host, port=port))
54
+ else:
55
+ asyncio.run(mcp_server.run_stdio_async())
56
+
57
+
58
+ @app.command()
59
+ def outline(
60
+ query: str = typer.Argument(..., help="Symbol name or file path fragment"),
61
+ path: Path = typer.Argument(
62
+ Path("."), help="Directory to search (default: current directory)"
63
+ ),
64
+ k: int = typer.Option(20, help="Max results"),
65
+ ) -> None:
66
+ """One-shot fuzzy symbol search — parses path, searches, exits. No persistent watcher."""
67
+ from .index import Index
68
+
69
+ root = path.expanduser().resolve()
70
+ idx = Index()
71
+ idx.full_scan(root, root)
72
+ symbols = idx.outline(root, query, k)
73
+ if not symbols:
74
+ console.print(f"[dim]No symbols found for[/dim] {query!r}")
75
+ return
76
+ for s in symbols:
77
+ console.print(
78
+ f" [cyan]{s.name}[/cyan] [L{s.start}-{s.end}] ({s.kind}) [dim]{s.file}[/dim]"
79
+ )
80
+
81
+
82
+ @app.command()
83
+ def find(
84
+ symbol: str = typer.Argument(..., help="Symbol name or substring"),
85
+ path: Path = typer.Argument(
86
+ Path("."), help="Directory to search (default: current directory)"
87
+ ),
88
+ ) -> None:
89
+ """One-shot symbol lookup by name — parses path, searches, exits."""
90
+ from .index import Index
91
+
92
+ root = path.expanduser().resolve()
93
+ idx = Index()
94
+ idx.full_scan(root, root)
95
+ symbols = idx.find(root, symbol)
96
+ if not symbols:
97
+ console.print(f"[dim]No symbol found matching[/dim] {symbol!r}")
98
+ return
99
+ for s in symbols:
100
+ console.print(
101
+ f" [cyan]{s.name}[/cyan] [L{s.start}-{s.end}] [dim]{s.file}[/dim]"
102
+ )
103
+
104
+
105
+ @app.command()
106
+ def status(
107
+ path: Path = typer.Argument(
108
+ Path("."), help="Directory to scan (default: current directory)"
109
+ ),
110
+ ) -> None:
111
+ """One-shot scan summary — files indexed, symbol count, by kind."""
112
+ from .parser import parse_directory
113
+
114
+ root = path.expanduser().resolve()
115
+ result = parse_directory(root)
116
+ by_kind: dict[str, int] = {}
117
+ for s in result.symbols:
118
+ by_kind[s.kind] = by_kind.get(s.kind, 0) + 1
119
+ console.print(f"[bold]{root}[/bold]")
120
+ console.print(
121
+ f" files indexed: {result.files_indexed} skipped: {result.files_skipped}"
122
+ )
123
+ for kind, count in sorted(by_kind.items()):
124
+ console.print(f" {kind}: {count}")
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # repo subcommands — operate on the persistent registry/worktree pool
129
+ # ---------------------------------------------------------------------------
130
+
131
+ repo_app = typer.Typer(
132
+ name="repo", help="Registered-git-repo worktree management.", no_args_is_help=True
133
+ )
134
+ app.add_typer(repo_app)
135
+
136
+
137
+ @repo_app.command("add")
138
+ def repo_add_cmd(url: str = typer.Argument(..., help="Git remote URL")) -> None:
139
+ """Clone url as a bare clone and register it."""
140
+ from .sources import repo as _repo
141
+
142
+ result = _repo.repo_add(url)
143
+ console.print(json.dumps(result, indent=2))
144
+
145
+
146
+ @repo_app.command("list")
147
+ def repo_list_cmd() -> None:
148
+ """List all registered repos."""
149
+ from .sources import repo as _repo
150
+
151
+ console.print(json.dumps(_repo.repo_list(), indent=2))
152
+
153
+
154
+ @repo_app.command("fetch")
155
+ def repo_fetch_cmd(
156
+ repo: str = typer.Argument(..., help="Registered repo name or URL"),
157
+ ) -> None:
158
+ """Fetch the latest refs for a registered repo."""
159
+ from .sources import repo as _repo
160
+
161
+ console.print(json.dumps(_repo.repo_fetch(repo), indent=2))
162
+
163
+
164
+ @repo_app.command("checkout")
165
+ def repo_checkout_cmd(
166
+ repo: str = typer.Argument(..., help="Registered repo name or URL"),
167
+ ref: str = typer.Argument(..., help="Branch, tag, or commit hash"),
168
+ user: str = typer.Option("default", "--user", "-u"),
169
+ ) -> None:
170
+ """Create or reuse a worktree for repo+ref."""
171
+ from .index import Index
172
+ from .sources import repo as _repo
173
+
174
+ idx = Index()
175
+ console.print(json.dumps(_repo.code_checkout(idx, repo, ref, user), indent=2))
176
+
177
+
178
+ @repo_app.command("outline")
179
+ def repo_outline_cmd(
180
+ repo: str = typer.Argument(...),
181
+ ref: str = typer.Argument(...),
182
+ query: str = typer.Argument(...),
183
+ user: str = typer.Option("default", "--user", "-u"),
184
+ k: int = typer.Option(20),
185
+ ) -> None:
186
+ """Fuzzy symbol search over a checked-out worktree (checkout first)."""
187
+ from .index import Index
188
+ from .sources import repo as _repo
189
+
190
+ idx = Index()
191
+ symbols, warnings = _repo.query_outline(idx, user, repo, ref, query, k)
192
+ for s in symbols:
193
+ console.print(
194
+ f" [cyan]{s.name}[/cyan] [L{s.start}-{s.end}] ({s.kind}) [dim]{s.file}[/dim]"
195
+ )
196
+ for w in warnings:
197
+ console.print(f"[yellow]WARNING:[/yellow] {w}")
@@ -0,0 +1,127 @@
1
+ """In-memory symbol index, keyed by an opaque handle.
2
+
3
+ A handle is whatever the caller wants — a resolved local root path, or a
4
+ (user, repo, ref) tuple for repo-mode. The index itself doesn't care; it
5
+ just holds a symbol list per handle and answers outline/find/read-adjacent
6
+ queries against it. Local-mode and repo-mode sources both populate the same
7
+ index through full_scan/update_file/remove_file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Hashable
15
+
16
+ from rapidfuzz import fuzz, process
17
+
18
+ from .parser import (
19
+ Symbol,
20
+ build_module_map,
21
+ parse_directory,
22
+ parse_file,
23
+ walk_python_files,
24
+ )
25
+
26
+ Handle = Hashable
27
+
28
+
29
+ class Index:
30
+ def __init__(self) -> None:
31
+ self._lock = threading.Lock()
32
+ self._symbols: dict[Handle, list[Symbol]] = {}
33
+ self._roots: dict[Handle, Path] = {}
34
+ self._module_maps: dict[Handle, dict[str, str]] = {}
35
+
36
+ def full_scan(self, handle: Handle, root: Path) -> int:
37
+ """Walk + parse everything under root, replacing any existing index for handle."""
38
+ result = parse_directory(root)
39
+ module_map = build_module_map(root, walk_python_files(root))
40
+ with self._lock:
41
+ self._symbols[handle] = result.symbols
42
+ self._roots[handle] = root
43
+ self._module_maps[handle] = module_map
44
+ return len(result.symbols)
45
+
46
+ def update_file(self, handle: Handle, path: Path) -> None:
47
+ """Re-parse a single file and replace its symbols in the index."""
48
+ root = self._roots.get(handle)
49
+ if root is None:
50
+ return
51
+ try:
52
+ rel = str(path.relative_to(root)).replace("\\", "/")
53
+ except ValueError:
54
+ return
55
+ file_result = parse_file(path, root, self._module_maps.get(handle))
56
+ with self._lock:
57
+ existing = self._symbols.get(handle, [])
58
+ kept = [s for s in existing if s.file != rel]
59
+ self._symbols[handle] = kept + (
60
+ [] if file_result.skipped else file_result.symbols
61
+ )
62
+
63
+ def remove_file(self, handle: Handle, path: Path) -> None:
64
+ """Drop all symbols for a deleted file."""
65
+ root = self._roots.get(handle)
66
+ if root is None:
67
+ return
68
+ try:
69
+ rel = str(path.relative_to(root)).replace("\\", "/")
70
+ except ValueError:
71
+ return
72
+ with self._lock:
73
+ existing = self._symbols.get(handle)
74
+ if existing is not None:
75
+ self._symbols[handle] = [s for s in existing if s.file != rel]
76
+
77
+ def evict(self, handle: Handle) -> None:
78
+ with self._lock:
79
+ self._symbols.pop(handle, None)
80
+ self._roots.pop(handle, None)
81
+ self._module_maps.pop(handle, None)
82
+
83
+ def has(self, handle: Handle) -> bool:
84
+ with self._lock:
85
+ return handle in self._symbols
86
+
87
+ def get_root(self, handle: Handle) -> Path | None:
88
+ return self._roots.get(handle)
89
+
90
+ def get_symbols(self, handle: Handle) -> list[Symbol] | None:
91
+ with self._lock:
92
+ symbols = self._symbols.get(handle)
93
+ return list(symbols) if symbols is not None else None
94
+
95
+ def outline(self, handle: Handle, query: str, k: int = 20) -> list[Symbol]:
96
+ """Fuzzy lexical search over symbol names and file paths."""
97
+ symbols = self.get_symbols(handle) or []
98
+ if not symbols:
99
+ return []
100
+ choices = [f"{s.name} {s.file}" for s in symbols]
101
+ results = process.extract(
102
+ query, choices, scorer=fuzz.WRatio, limit=k, score_cutoff=35
103
+ )
104
+ return [symbols[idx] for _, _, idx in results]
105
+
106
+ def outline_paths(
107
+ self, handle: Handle, paths: list[str]
108
+ ) -> tuple[list[Symbol], list[str]]:
109
+ """Exact-file symbol outline lookup for a batch of paths, bypassing fuzzy search."""
110
+ symbols = self.get_symbols(handle) or []
111
+ by_file: dict[str, list[Symbol]] = {}
112
+ for s in symbols:
113
+ by_file.setdefault(s.file, []).append(s)
114
+
115
+ matched: list[Symbol] = []
116
+ warnings: list[str] = []
117
+ for p in paths:
118
+ if p in by_file:
119
+ matched.extend(by_file[p])
120
+ else:
121
+ warnings.append(f"no symbols found for path: {p!r}")
122
+ return matched, warnings
123
+
124
+ def find(self, handle: Handle, symbol: str) -> list[Symbol]:
125
+ """Substring match by symbol name."""
126
+ symbols = self.get_symbols(handle) or []
127
+ return [s for s in symbols if symbol in s.name]
@@ -0,0 +1 @@
1
+ """eulerian MCP server package."""
@@ -0,0 +1,14 @@
1
+ """FastMCP singleton — leaf module with no package imports."""
2
+
3
+ from fastmcp import FastMCP
4
+
5
+ server = FastMCP(
6
+ name="eulerian",
7
+ instructions=(
8
+ "Real-time code navigation. Local mode: call watch(path) once, then "
9
+ "outline/find/read against it — the index reflects on-disk edits "
10
+ "instantly, no re-indexing calls needed. Repo mode: repo_add a git "
11
+ "remote, checkout a ref, then repo_outline/repo_read against it — "
12
+ "freshness is checked against the remote on each call."
13
+ ),
14
+ )
@@ -0,0 +1,7 @@
1
+ """FastMCP server entry point — importing tools registers their @server.tool() decorators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._instance import server # noqa: F401 — re-exported; callers do `from .mcp.server import server`
6
+
7
+ from . import tools # noqa: F401, E402