omphalos 1.1.1__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.
omphalos/__init__.py ADDED
File without changes
omphalos/cache.py ADDED
@@ -0,0 +1,58 @@
1
+ # omphalos/cache.py
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ CACHE_FILE = ".omphcache"
9
+ # Increment this number when the cache data structure changes to invalidate old caches.
10
+ # v3: symbols stored as structured dicts (kind/name/line/doc/label/children)
11
+ # v4: function/method symbols gained optional `calls` and `raises` lists
12
+ # v5: calls exclude signature/decorator/defaults and collapse chained callees
13
+ CACHE_VERSION = 5
14
+
15
+ def calculate_sha256(content: bytes) -> str:
16
+ """Calculate the SHA-256 checksum of raw file bytes for change detection."""
17
+ return hashlib.sha256(content).hexdigest()
18
+
19
+ def load_cache(root_dir: Path) -> dict:
20
+ """Load symbol cache from .omphcache.
21
+
22
+ Returns an empty dict if the cache file does not exist, fails to parse,
23
+ or has an incompatible cache version.
24
+ """
25
+ cache_path = root_dir / CACHE_FILE
26
+ if not cache_path.exists():
27
+ return {}
28
+ try:
29
+ raw = json.loads(cache_path.read_text(encoding="utf-8"))
30
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
31
+ return {}
32
+ # Discard cache created with a different schema version
33
+ if not isinstance(raw, dict) or raw.get("version") != CACHE_VERSION:
34
+ return {}
35
+ files = raw.get("files")
36
+ return files if isinstance(files, dict) else {}
37
+
38
+ def save_cache(root_dir: Path, cache: dict):
39
+ """Save symbol cache to .omphcache atomically.
40
+
41
+ Writes to a temporary file first and replaces the target file via os.replace
42
+ to prevent file corruption if interrupted.
43
+ """
44
+ cache_path = root_dir / CACHE_FILE
45
+ payload = {"version": CACHE_VERSION, "files": cache}
46
+ fd, tmp_name = tempfile.mkstemp(
47
+ dir=cache_path.parent, prefix=f"{CACHE_FILE}.", suffix=".tmp"
48
+ )
49
+ try:
50
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
51
+ json.dump(payload, f, indent=2, ensure_ascii=False)
52
+ os.replace(tmp_name, cache_path)
53
+ except BaseException:
54
+ try:
55
+ os.unlink(tmp_name)
56
+ except OSError:
57
+ pass
58
+ raise
omphalos/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ # omphalos/cli.py
2
+ import sys
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from omphalos.cache import calculate_sha256, load_cache, save_cache
9
+ from omphalos.ignore import (
10
+ IGNORE_FILE,
11
+ NotAGitRepoError,
12
+ collect_files,
13
+ collect_git_files,
14
+ ensure_ignore_file,
15
+ load_ignore_spec,
16
+ )
17
+ from omphalos.parser import SUPPORTED_EXTENSIONS, extract_symbols
18
+ from omphalos.render import render_json, render_markdown
19
+
20
+ INDEX_FILE = "INDEX.md"
21
+ FORMATS = ("markdown", "json")
22
+
23
+ app = typer.Typer(
24
+ add_completion=False,
25
+ )
26
+
27
+
28
+ def _package_version() -> str:
29
+ """Read the installed package version (single source of truth: pyproject.toml)."""
30
+ try:
31
+ return version("omphalos")
32
+ except PackageNotFoundError:
33
+ return "unknown"
34
+
35
+
36
+ def _version_callback(value: bool):
37
+ """Callback for --version flag to print the version and exit immediately."""
38
+ if value:
39
+ typer.echo(f"omph {_package_version()} (omphalos)")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback(invoke_without_command=True)
44
+ def main(
45
+ ctx: typer.Context,
46
+ version: bool = typer.Option(
47
+ False, "--version", callback=_version_callback, is_eager=True,
48
+ help="Show version and exit.",
49
+ )
50
+ ):
51
+ """Omphalos - Codebase symbol indexer for LLMs.
52
+
53
+ Quick start:
54
+
55
+ omph scan Scan repository and generate INDEX.md
56
+
57
+ omph scan --check Verify index is up to date (exit 1 if stale)
58
+
59
+ omph scan --git-only Scan only files tracked by Git
60
+
61
+ omph scan --format json Export index as JSON
62
+
63
+ omph init Create a default .omphignore file
64
+
65
+ For full options of each command, run: omph COMMAND --help
66
+ """
67
+ if ctx.invoked_subcommand is None:
68
+ typer.echo(ctx.get_help())
69
+ raise typer.Exit()
70
+
71
+
72
+ @app.command()
73
+ def init(
74
+ target_dir: str = typer.Argument(".", help="Target directory to initialize")
75
+ ):
76
+ """Create a default .omphignore file if not present in the target directory."""
77
+ root = Path(target_dir).resolve()
78
+ created = ensure_ignore_file(root)
79
+ if created:
80
+ typer.echo(f"Created {created.name}")
81
+ else:
82
+ typer.echo(f"Ignore file already exists ({IGNORE_FILE}).")
83
+
84
+
85
+ @app.command()
86
+ def scan(
87
+ target_dir: str = typer.Argument(".", help="Target directory to scan"),
88
+ output: Path = typer.Option(
89
+ None, "--output", "-o",
90
+ help=f"Index output path (default: {INDEX_FILE} in the target directory)",
91
+ ),
92
+ fmt: str = typer.Option(
93
+ "markdown", "--format", help=f"Output format: {' or '.join(FORMATS)}"
94
+ ),
95
+ no_cache: bool = typer.Option(
96
+ False, "--no-cache", help="Ignore cached symbols and re-parse every file"
97
+ ),
98
+ check: bool = typer.Option(
99
+ False, "--check",
100
+ help="Verify the index is up to date without writing; exit 1 if stale",
101
+ ),
102
+ git_only: bool = typer.Option(
103
+ False, "--git-only", help="Only scan files tracked by git (requires a git repository)"
104
+ ),
105
+ ):
106
+ """Scan multi-language codebase files and generate a symbol index with line numbers and docstrings."""
107
+ root = Path(target_dir).resolve()
108
+ if fmt not in FORMATS:
109
+ typer.echo(f"error: invalid --format '{fmt}' (choose from {', '.join(FORMATS)})", err=True)
110
+ raise typer.Exit(2)
111
+
112
+ if not check:
113
+ created = ensure_ignore_file(root)
114
+ if created:
115
+ typer.echo(f"Created default {created.name}")
116
+
117
+ spec = load_ignore_spec(root)
118
+
119
+ try:
120
+ if git_only:
121
+ target_files = collect_git_files(root, spec, SUPPORTED_EXTENSIONS)
122
+ else:
123
+ target_files = collect_files(root, spec, SUPPORTED_EXTENSIONS)
124
+ except NotAGitRepoError as e:
125
+ typer.echo(f"error: --git-only requires a git repository ({e})", err=True)
126
+ raise typer.Exit(1) from e
127
+
128
+ cache = {} if no_cache else load_cache(root)
129
+ new_cache = {}
130
+ files_data: dict[str, dict] = {}
131
+ failed = 0
132
+
133
+ with typer.progressbar(target_files, label="Indexing", file=sys.stderr) as progress:
134
+ for file_path in progress:
135
+ rel_posix = file_path.relative_to(root).as_posix()
136
+ try:
137
+ content = file_path.read_bytes()
138
+ except OSError as e:
139
+ typer.echo(f"error: cannot read {rel_posix}: {e}", err=True)
140
+ failed += 1
141
+ continue
142
+
143
+ file_hash = calculate_sha256(content)
144
+ # Check whether cached symbol data is reusable
145
+ cached = cache.get(rel_posix)
146
+ if cached and cached.get("hash") == file_hash and isinstance(cached.get("data"), dict):
147
+ data = cached["data"]
148
+ else:
149
+ data = extract_symbols(file_path, content)
150
+
151
+ new_cache[rel_posix] = {"hash": file_hash, "data": data}
152
+ files_data[rel_posix] = data
153
+
154
+ if data.get("error"):
155
+ typer.echo(f"error: cannot parse {rel_posix}: {data['error']}", err=True)
156
+ failed += 1
157
+
158
+ renderer = render_json if fmt == "json" else render_markdown
159
+ rendered = renderer(files_data)
160
+ index_path = output if output else root / INDEX_FILE
161
+
162
+ if check:
163
+ existing = index_path.read_text(encoding="utf-8") if index_path.exists() else None
164
+ if existing == rendered:
165
+ typer.echo("Index is up to date.")
166
+ else:
167
+ typer.echo(f"Index is out of date: {index_path}", err=True)
168
+ raise typer.Exit(1)
169
+ return
170
+
171
+ # Write index file and persist updated cache
172
+ index_path.write_text(rendered, encoding="utf-8")
173
+ save_cache(root, new_cache)
174
+ summary = f"Updated {index_path.name} successfully ({len(target_files)} files scanned)."
175
+ if failed:
176
+ summary = f"Updated {index_path.name} ({len(target_files)} files scanned, {failed} failed)."
177
+ typer.echo(summary)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ app()
omphalos/ignore.py ADDED
@@ -0,0 +1,168 @@
1
+ # omphalos/ignore.py
2
+ import os
3
+ import subprocess
4
+ from collections.abc import Container
5
+ from pathlib import Path
6
+
7
+ import pathspec
8
+
9
+ DEFAULT_IGNORES = [
10
+ ".git/",
11
+ ".omphcache",
12
+ ".omphalos_cache.json",
13
+ "INDEX.md",
14
+ ".omphalos_index",
15
+ ".omphignore",
16
+ ".omphalos_ignore",
17
+ "__pycache__/",
18
+ ".pytest_cache/",
19
+ ".mypy_cache/",
20
+ ".ruff_cache/",
21
+ "node_modules/",
22
+ ".venv/",
23
+ "venv/",
24
+ "dist/",
25
+ "build/",
26
+ "*.lock",
27
+ "*.min.js",
28
+ ]
29
+
30
+ IGNORE_TEMPLATE = """# .omphignore
31
+ # Files and directories to ignore when scanning codebase symbols.
32
+ # Uses gitignore pattern syntax.
33
+
34
+ # Version control & environments
35
+ .git/
36
+ .venv/
37
+ venv/
38
+ node_modules/
39
+
40
+ # Cache & build outputs
41
+ __pycache__/
42
+ .pytest_cache/
43
+ .mypy_cache/
44
+ .ruff_cache/
45
+ dist/
46
+ build/
47
+ *.egg-info/
48
+
49
+ # Omphalos cache & output
50
+ .omphcache
51
+ INDEX.md
52
+
53
+ # Locks & minified files
54
+ *.lock
55
+ *.min.js
56
+ """
57
+
58
+ IGNORE_FILE = ".omphignore"
59
+
60
+ def ensure_ignore_file(root_dir: Path) -> Path | None:
61
+ """Create a default .omphignore file if it does not already exist.
62
+
63
+ Returns:
64
+ Path of the newly created ignore file, or None if it already exists.
65
+ """
66
+ target = root_dir / IGNORE_FILE
67
+ if target.exists():
68
+ return None
69
+
70
+ target.write_text(IGNORE_TEMPLATE.strip() + "\n", encoding="utf-8")
71
+ return target
72
+
73
+ def load_ignore_spec(root_dir: Path) -> pathspec.PathSpec:
74
+ """Load and merge all ignore rules into a unified PathSpec.
75
+
76
+ Precedence order:
77
+ 1. DEFAULT_IGNORES (e.g., .git, venv, caches)
78
+ 2. Rules from .gitignore (if present)
79
+ 3. Rules from .omphignore (and legacy .omphalos_ignore if present)
80
+
81
+ Returns:
82
+ GitIgnoreSpec matcher for filtering paths.
83
+ """
84
+ patterns = list(DEFAULT_IGNORES)
85
+
86
+ # Order matters: ignore files come after .gitignore to allow overriding with negation patterns
87
+ for filename in [".gitignore", ".omphalos_ignore", IGNORE_FILE]:
88
+ ignore_file = root_dir / filename
89
+ if ignore_file.exists():
90
+ patterns.extend(ignore_file.read_text(encoding="utf-8").splitlines())
91
+
92
+ # Use GitIgnoreSpec for git-compliant semantics (negation, directory matching)
93
+ return pathspec.GitIgnoreSpec.from_lines("gitignore", patterns)
94
+
95
+
96
+ def collect_files(
97
+ root_dir: Path, spec: pathspec.PathSpec, supported_extensions: Container[str]
98
+ ) -> list[Path]:
99
+ """Recursively collect supported files, pruning directories matched by ignore spec.
100
+
101
+ Note: Directories matching ignore rules are pruned immediately during os.walk,
102
+ matching git behavior (negation patterns cannot re-include files within an
103
+ ignored directory).
104
+ """
105
+ target_files: list[Path] = []
106
+ for dirpath, dirnames, filenames in os.walk(root_dir):
107
+ rel_dir = Path(dirpath).relative_to(root_dir)
108
+ dirnames[:] = [
109
+ d for d in dirnames
110
+ if not spec.match_file(f"{(rel_dir / d).as_posix()}/")
111
+ ]
112
+ for f in filenames:
113
+ p = Path(f)
114
+ if p.suffix.lower() in supported_extensions:
115
+ rel_file = (rel_dir / f).as_posix()
116
+ if not spec.match_file(rel_file):
117
+ target_files.append(root_dir / rel_file)
118
+
119
+ target_files.sort()
120
+ return target_files
121
+
122
+
123
+ class NotAGitRepoError(Exception):
124
+ pass
125
+
126
+
127
+ def collect_git_files(
128
+ root_dir: Path, spec: pathspec.PathSpec, supported_extensions: Container[str]
129
+ ) -> list[Path]:
130
+ """Collect only files tracked by Git (via git ls-files), filtered by spec and extension.
131
+
132
+ Args:
133
+ root_dir: Root directory of the Git repository.
134
+ spec: PathSpec ignore filter.
135
+ supported_extensions: Collection of supported file extensions.
136
+
137
+ Returns:
138
+ List of matching file Paths that exist in the working tree.
139
+
140
+ Raises:
141
+ NotAGitRepoError: If root_dir is not inside a git repository or git binary is missing.
142
+ """
143
+ try:
144
+ proc = subprocess.run(
145
+ ["git", "ls-files", "-z"], cwd=root_dir,
146
+ capture_output=True, check=False,
147
+ )
148
+ except FileNotFoundError as e:
149
+ raise NotAGitRepoError("git command not found") from e
150
+ if proc.returncode != 0:
151
+ msg = proc.stderr.decode("utf-8", errors="replace").strip()
152
+ raise NotAGitRepoError(msg or "not a git repository")
153
+
154
+ target_files: list[Path] = []
155
+ for chunk in proc.stdout.split(b"\0"):
156
+ if not chunk:
157
+ continue
158
+ rel = chunk.decode("utf-8", errors="replace").replace("\\", "/")
159
+ p = Path(rel)
160
+ if p.suffix.lower() not in supported_extensions:
161
+ continue
162
+ if not (root_dir / p).is_file():
163
+ continue # Present in git index but deleted from working tree
164
+ if not spec.match_file(rel):
165
+ target_files.append(root_dir / p)
166
+
167
+ target_files.sort()
168
+ return target_files
omphalos/parser.py ADDED
@@ -0,0 +1,519 @@
1
+ # omphalos/parser.py
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import tree_sitter as ts
6
+ import tree_sitter_go as tsgo
7
+ import tree_sitter_javascript as tsjs
8
+ import tree_sitter_python as tspython
9
+ import tree_sitter_rust as tsrust
10
+ import tree_sitter_typescript as tsts
11
+
12
+ # Initialize Languages
13
+ _LANG_PYTHON = ts.Language(tspython.language())
14
+ _LANG_TS = ts.Language(tsts.language_typescript())
15
+ _LANG_TSX = ts.Language(tsts.language_tsx())
16
+ _LANG_JS = ts.Language(tsjs.language())
17
+ _LANG_GO = ts.Language(tsgo.language())
18
+ _LANG_RUST = ts.Language(tsrust.language())
19
+
20
+ # Parsers
21
+ _PARSER_PYTHON = ts.Parser(_LANG_PYTHON)
22
+ _PARSER_TS = ts.Parser(_LANG_TS)
23
+ _PARSER_TSX = ts.Parser(_LANG_TSX)
24
+ _PARSER_JS = ts.Parser(_LANG_JS)
25
+ _PARSER_GO = ts.Parser(_LANG_GO)
26
+ _PARSER_RUST = ts.Parser(_LANG_RUST)
27
+
28
+ SUPPORTED_EXTENSIONS = {
29
+ ".py": "python",
30
+ ".ts": "typescript",
31
+ ".tsx": "tsx",
32
+ ".js": "javascript",
33
+ ".jsx": "tsx",
34
+ ".mjs": "javascript",
35
+ ".cjs": "javascript",
36
+ ".go": "go",
37
+ ".rs": "rust",
38
+ }
39
+
40
+
41
+ def _text(node: ts.Node) -> str:
42
+ """Decode Tree-sitter AST node text bytes to a UTF-8 string."""
43
+ return (node.text or b"").decode("utf-8", errors="replace")
44
+
45
+
46
+ def _mk_symbol(kind: str, name: str, node: ts.Node, label: str, doc: str = "") -> dict[str, Any]:
47
+ """Create a standardized symbol schema dictionary.
48
+
49
+ Args:
50
+ kind: Type of symbol (e.g., function, class, method, struct, interface).
51
+ name: Identifier name of the symbol.
52
+ node: Originating Tree-sitter AST node for extracting 1-indexed start line.
53
+ label: Human/LLM-readable display label.
54
+ doc: First-line summary or docstring (if available).
55
+ """
56
+ return {
57
+ "kind": kind,
58
+ "name": name,
59
+ "label": label,
60
+ "line": node.start_point[0] + 1,
61
+ "doc": doc,
62
+ "exported": False,
63
+ "children": [],
64
+ }
65
+
66
+
67
+ def _prev_comment_block(node: ts.Node) -> list[ts.Node]:
68
+ """Return contiguous comment nodes immediately preceding the node (in top-to-bottom order).
69
+
70
+ Note: Certain grammars (like Rust) include the trailing newline in line_comment,
71
+ causing end_point to fall on the next line. We strip trailing newlines before checking contiguity.
72
+ """
73
+ def content_end_row(c: ts.Node) -> int:
74
+ text = c.text or b""
75
+ return c.end_point[0] - (len(text) - len(text.rstrip(b"\r\n")))
76
+
77
+ block = []
78
+ cur = node.prev_named_sibling
79
+ row = node.start_point[0]
80
+ while cur is not None and cur.type in ("comment", "line_comment") and content_end_row(cur) == row - 1:
81
+ block.append(cur)
82
+ row = cur.start_point[0]
83
+ cur = cur.prev_named_sibling
84
+ block.reverse()
85
+ return block
86
+
87
+
88
+ def _get_py_docstring(node: ts.Node) -> str:
89
+ """Extract the first line of a Python function or class docstring, if present."""
90
+ body = node.child_by_field_name("body")
91
+ if body and len(body.named_children) > 0:
92
+ first = body.named_children[0]
93
+ if first.type == "expression_statement" and len(first.named_children) > 0:
94
+ str_node = first.named_children[0]
95
+ if str_node.type == "string":
96
+ # Intentional charset strip: remove repeated quotes (""" or ''') and surrounding whitespace
97
+ raw = _text(str_node).strip("\"' \n\r\t") # noqa: B005
98
+ return raw.splitlines()[0].strip() if raw else ""
99
+ return ""
100
+
101
+
102
+ def _go_doc(node: ts.Node) -> str:
103
+ """Go doc comment: first line of comment block attached above declaration (skipping //go: directives)."""
104
+ for c in _prev_comment_block(node):
105
+ t = _text(c).strip()
106
+ if t.startswith("//go:"):
107
+ continue
108
+ return t.removeprefix("//").strip()
109
+ return ""
110
+
111
+
112
+ def _rust_doc(node: ts.Node) -> str:
113
+ """Rust doc comment: first line of /// comment block attached above an item."""
114
+ for c in _prev_comment_block(node):
115
+ t = _text(c).strip()
116
+ if t.startswith("///"):
117
+ line = t.removeprefix("///").strip()
118
+ if line:
119
+ return line
120
+ return ""
121
+
122
+
123
+ def _ts_doc(node: ts.Node) -> str:
124
+ """JSDoc comment: first line containing content within a /** ... */ block attached above declaration."""
125
+ for c in _prev_comment_block(node):
126
+ t = _text(c).strip()
127
+ if not t.startswith("/**"):
128
+ continue
129
+ for raw in t.splitlines():
130
+ line = raw.strip().removeprefix("/**").removeprefix("*").removesuffix("*/").strip()
131
+ if line:
132
+ return line
133
+ return ""
134
+
135
+
136
+ def _unwrap_decorated(node: ts.Node) -> ts.Node:
137
+ """Unwrap core definition node (function/class) from a Python decorated_definition."""
138
+ if node.type == "decorated_definition":
139
+ for child in node.named_children:
140
+ if child.type in ("function_definition", "async_function_definition", "class_definition"):
141
+ return child
142
+ return node
143
+
144
+
145
+ # Node-type rules for internal logic extraction, per language family:
146
+ # {call-like node type: field name holding the callee expression}
147
+ _CALL_FIELDS = {
148
+ "python": {"call": "function"},
149
+ "ts_js": {"call_expression": "function", "new_expression": "constructor"},
150
+ "go": {"call_expression": "function"},
151
+ "rust": {"call_expression": "function"},
152
+ }
153
+ # Statement types that propagate errors outward (Go/Rust use return values instead)
154
+ _RAISE_NODES = {"python": "raise_statement", "ts_js": "throw_statement"}
155
+
156
+
157
+ def _raise_target(node: ts.Node) -> str:
158
+ """Extract the exception type from a raise/throw statement.
159
+
160
+ Covers `raise X`, `raise X(...)`, `throw new X(...)`, and bare identifier re-throws.
161
+ """
162
+ if not node.named_children:
163
+ return ""
164
+ first = node.named_children[0]
165
+ if first.type in ("call", "call_expression"):
166
+ callee = first.child_by_field_name("function")
167
+ return _text(callee) if callee else ""
168
+ if first.type == "new_expression":
169
+ ctor = first.child_by_field_name("constructor")
170
+ return _text(ctor) if ctor else ""
171
+ if first.type in ("identifier", "scoped_identifier", "scoped_type_identifier"):
172
+ return _text(first)
173
+ return ""
174
+
175
+
176
+ def _is_simple_chain(node: ts.Node) -> bool:
177
+ """True ถ้า node เป็นโซ่ identifier เรียบ ๆ เช่น `self`, `log`, `file_path.relative_to`
178
+
179
+ ใช้แยกว่า callee ควรแสดงเต็ม (`self._flush`) หรือย่อเหลือ property ปลาย
180
+ เมื่อ object ต้นทางเป็นนิพจน์ซับซ้อน (`(a / b).as_posix`, `f(x).strip`)
181
+ """
182
+ if node.type in ("identifier", "type_identifier", "self", "this", "super",
183
+ "field_identifier", "property_identifier"):
184
+ return True
185
+ kids = node.named_children
186
+ if node.type in ("attribute", "member_expression", "selector_expression", "field_expression",
187
+ "scoped_identifier", "scoped_type_identifier") and kids:
188
+ return _is_simple_chain(kids[0])
189
+ return False
190
+
191
+
192
+ def _callee_name(callee: ts.Node) -> str:
193
+ """Compact callee name: dotted chain เต็มถ้าต้นทางเรียบ, ไม่งั้นเหลือเฉพาะชื่อปลาย"""
194
+ kids = callee.named_children
195
+ if len(kids) < 2 or _is_simple_chain(kids[0]):
196
+ return _text(callee)
197
+ return _text(kids[-1])
198
+
199
+
200
+ def _collect_logic(node: ts.Node, family: str) -> tuple[list[str], list[str]]:
201
+ """Collect deduplicated internal call targets and raised exception types of a function body.
202
+
203
+ Only names are recorded (not arguments) to keep the index compact yet signal-dense.
204
+ Raise/throw subtrees are not descended into: the raised expression is an error type,
205
+ not a call target, and would otherwise appear in both lists.
206
+ """
207
+ call_fields = _CALL_FIELDS[family]
208
+ raise_type = _RAISE_NODES.get(family)
209
+ calls: list[str] = []
210
+ raises: list[str] = []
211
+ stack = [node]
212
+ while stack:
213
+ n = stack.pop()
214
+ if raise_type and n.type == raise_type:
215
+ name = _raise_target(n)
216
+ if name and name not in raises:
217
+ raises.append(name)
218
+ continue
219
+ field = call_fields.get(n.type)
220
+ if field:
221
+ callee = n.child_by_field_name(field)
222
+ if callee:
223
+ name = _callee_name(callee)
224
+ if name and name not in calls:
225
+ calls.append(name)
226
+ stack.extend(reversed(n.named_children))
227
+ return calls, raises
228
+
229
+
230
+ def _annotate_logic(sym: dict[str, Any], node: ts.Node, family: str, force: bool = False):
231
+ """Attach `calls`/`raises` to function-like symbols (keys added only when non-empty).
232
+
233
+ Scans only the function *body*: calls appearing in signatures, decorators,
234
+ or parameter defaults are declaration-time expressions, not runtime logic.
235
+ """
236
+ if not force and sym["kind"] not in ("function", "method"):
237
+ return
238
+ body = node.child_by_field_name("body") or node
239
+ calls, raises = _collect_logic(body, family)
240
+ if calls:
241
+ sym["calls"] = calls
242
+ if raises:
243
+ sym["raises"] = raises
244
+
245
+
246
+ def _extract_python(root_node: ts.Node) -> list[dict[str, Any]]:
247
+ """Extract all symbols from a Python AST (classes, methods, functions)."""
248
+ symbols = []
249
+ for raw_node in root_node.children:
250
+ node = _unwrap_decorated(raw_node)
251
+ if node.type == "class_definition":
252
+ name_node = node.child_by_field_name("name")
253
+ if name_node:
254
+ name = _text(name_node)
255
+ cls = _mk_symbol("class", name, node, f"class `{name}`", _get_py_docstring(node))
256
+
257
+ body = node.child_by_field_name("body")
258
+ for child_raw in (body.named_children if body else []):
259
+ child = _unwrap_decorated(child_raw)
260
+ if child.type in ("function_definition", "async_function_definition"):
261
+ m_name_node = child.child_by_field_name("name")
262
+ if m_name_node:
263
+ m_name = _text(m_name_node)
264
+ m = _mk_symbol("method", m_name, child, f"`{m_name}()`", _get_py_docstring(child))
265
+ _annotate_logic(m, child, "python")
266
+ cls["children"].append(m)
267
+ symbols.append(cls)
268
+
269
+ elif node.type in ("function_definition", "async_function_definition"):
270
+ name_node = node.child_by_field_name("name")
271
+ if name_node:
272
+ name = _text(name_node)
273
+ fn = _mk_symbol("function", name, node, f"`{name}()`", _get_py_docstring(node))
274
+ _annotate_logic(fn, node, "python")
275
+ symbols.append(fn)
276
+
277
+ return symbols
278
+
279
+
280
+ def _ts_symbol(target: ts.Node, doc_node: ts.Node, exported: bool) -> dict[str, Any] | None:
281
+ """Extract a structured symbol dictionary from a TypeScript/JavaScript declaration node."""
282
+ prefix = "export " if exported else ""
283
+
284
+ if target.type == "interface_declaration":
285
+ name_node = target.child_by_field_name("name")
286
+ if name_node:
287
+ return _mk_symbol("interface", _text(name_node), target,
288
+ f"{prefix}interface `{_text(name_node)}`", _ts_doc(doc_node))
289
+
290
+ elif target.type == "type_alias_declaration":
291
+ name_node = target.child_by_field_name("name")
292
+ if name_node:
293
+ return _mk_symbol("type", _text(name_node), target,
294
+ f"{prefix}type `{_text(name_node)}`", _ts_doc(doc_node))
295
+
296
+ elif target.type == "function_declaration":
297
+ name_node = target.child_by_field_name("name")
298
+ if name_node:
299
+ fn = _mk_symbol("function", _text(name_node), target,
300
+ f"{prefix}function `{_text(name_node)}()`", _ts_doc(doc_node))
301
+ _annotate_logic(fn, target, "ts_js")
302
+ return fn
303
+
304
+ elif target.type == "class_declaration":
305
+ name_node = target.child_by_field_name("name")
306
+ if name_node:
307
+ cls = _mk_symbol("class", _text(name_node), target,
308
+ f"{prefix}class `{_text(name_node)}`", _ts_doc(doc_node))
309
+ body = target.child_by_field_name("body")
310
+ if body:
311
+ for item in body.named_children:
312
+ if item.type == "method_definition":
313
+ m_name_node = item.child_by_field_name("name")
314
+ if m_name_node:
315
+ m = _mk_symbol(
316
+ "method", _text(m_name_node), item, f"`{_text(m_name_node)}()`", _ts_doc(item))
317
+ _annotate_logic(m, item, "ts_js")
318
+ cls["children"].append(m)
319
+ return cls
320
+
321
+ elif target.type == "lexical_declaration":
322
+ kind_word = _text(target.children[0]) if target.children else "const"
323
+ kind = "constant" if kind_word == "const" else "variable"
324
+ for decl in target.named_children:
325
+ if decl.type == "variable_declarator":
326
+ name_node = decl.child_by_field_name("name")
327
+ value_node = decl.child_by_field_name("value")
328
+ if name_node:
329
+ name = _text(name_node)
330
+ is_fn = value_node and value_node.type in ("arrow_function", "function_expression")
331
+ suffix = "()" if is_fn else ""
332
+ sym = _mk_symbol(kind, name, target, f"{prefix}{kind_word} `{name}{suffix}`", _ts_doc(doc_node))
333
+ if is_fn and value_node:
334
+ # Arrow/function-expression constants behave as functions: collect their logic
335
+ _annotate_logic(sym, value_node, "ts_js", force=True)
336
+ return sym
337
+
338
+ return None
339
+
340
+
341
+ def _extract_ts_js(root_node: ts.Node) -> list[dict[str, Any]]:
342
+ """Extract symbols from TypeScript and JavaScript ASTs (functions, classes, interfaces, types, exports)."""
343
+ symbols = []
344
+ for node in root_node.children:
345
+ exported = False
346
+ target = node
347
+
348
+ if node.type == "export_statement":
349
+ exported = True
350
+ named = [c for c in node.named_children if c.type not in ("comment",)]
351
+ if named:
352
+ target = named[0]
353
+
354
+ # Doc comment is attached above export_statement (or declaration itself if not exported)
355
+ sym = _ts_symbol(target, node, exported)
356
+ if sym:
357
+ sym["exported"] = exported
358
+ symbols.append(sym)
359
+
360
+ return symbols
361
+
362
+
363
+ def _extract_go(root_node: ts.Node) -> list[dict[str, Any]]:
364
+ """Extract symbols from Go AST (types, structs, interfaces, functions, methods with receivers)."""
365
+ symbols = []
366
+ for node in root_node.children:
367
+ if node.type == "type_declaration":
368
+ specs = [c for c in node.named_children if c.type == "type_spec"]
369
+ for spec in specs:
370
+ name_node = spec.child_by_field_name("name")
371
+ type_val = spec.child_by_field_name("type")
372
+ if not name_node or type_val is None:
373
+ continue
374
+ t_kind = {"struct_type": "struct", "interface_type": "interface"}.get(type_val.type, "type")
375
+ suffix = {"struct": " (struct)", "interface": " (interface)"}.get(t_kind, "")
376
+ name = _text(name_node)
377
+ # Grouped type block: doc comment sits above type_spec; single type: above type_declaration
378
+ doc = _go_doc(spec)
379
+ if not doc and len(specs) == 1:
380
+ doc = _go_doc(node)
381
+ symbols.append(_mk_symbol(t_kind, name, spec, f"type `{name}`{suffix}", doc))
382
+
383
+ elif node.type == "function_declaration":
384
+ name_node = node.child_by_field_name("name")
385
+ if name_node:
386
+ name = _text(name_node)
387
+ fn = _mk_symbol("function", name, node, f"func `{name}()`", _go_doc(node))
388
+ _annotate_logic(fn, node, "go")
389
+ symbols.append(fn)
390
+
391
+ elif node.type == "method_declaration":
392
+ recv = node.child_by_field_name("receiver")
393
+ name_node = node.child_by_field_name("name")
394
+ if name_node:
395
+ name = _text(name_node)
396
+ recv_str = _text(recv) if recv else ""
397
+ m = _mk_symbol("method", name, node, f"func `{recv_str} {name}()`", _go_doc(node))
398
+ _annotate_logic(m, node, "go")
399
+ symbols.append(m)
400
+
401
+ return symbols
402
+
403
+
404
+ def _rust_vis(node: ts.Node) -> str:
405
+ """Extract visibility modifier string (e.g., 'pub') from a Rust item node."""
406
+ for c in node.children:
407
+ if c.type == "visibility_modifier":
408
+ return _text(c)
409
+ return ""
410
+
411
+
412
+ def _extract_rust(root_node: ts.Node) -> list[dict[str, Any]]:
413
+ """Extract symbols from Rust AST (structs, enums, traits, functions, and impl blocks)."""
414
+ symbols = []
415
+ for node in root_node.children:
416
+ vis = _rust_vis(node)
417
+ vis_str = f"{vis} " if vis else ""
418
+
419
+ if node.type == "struct_item":
420
+ name_node = node.child_by_field_name("name")
421
+ if name_node:
422
+ name = _text(name_node)
423
+ symbols.append(_mk_symbol("struct", name, node, f"{vis_str}struct `{name}`", _rust_doc(node)))
424
+
425
+ elif node.type == "enum_item":
426
+ name_node = node.child_by_field_name("name")
427
+ if name_node:
428
+ name = _text(name_node)
429
+ symbols.append(_mk_symbol("enum", name, node, f"{vis_str}enum `{name}`", _rust_doc(node)))
430
+
431
+ elif node.type == "trait_item":
432
+ name_node = node.child_by_field_name("name")
433
+ if name_node:
434
+ name = _text(name_node)
435
+ symbols.append(_mk_symbol("trait", name, node, f"{vis_str}trait `{name}`", _rust_doc(node)))
436
+
437
+ elif node.type == "function_item":
438
+ name_node = node.child_by_field_name("name")
439
+ if name_node:
440
+ name = _text(name_node)
441
+ fn = _mk_symbol("function", name, node, f"{vis_str}fn `{name}()`", _rust_doc(node))
442
+ _annotate_logic(fn, node, "rust")
443
+ symbols.append(fn)
444
+
445
+ elif node.type == "impl_item":
446
+ trait_node = node.child_by_field_name("trait")
447
+ type_node = node.child_by_field_name("type")
448
+ impl_header = ""
449
+ if trait_node and type_node:
450
+ impl_header = f"{_text(trait_node)} for {_text(type_node)}"
451
+ elif type_node:
452
+ impl_header = _text(type_node)
453
+
454
+ if impl_header:
455
+ impl = _mk_symbol("impl", impl_header, node, f"impl `{impl_header}`", _rust_doc(node))
456
+ body = node.child_by_field_name("body")
457
+ if body:
458
+ for item in body.named_children:
459
+ if item.type == "function_item":
460
+ m_vis = _rust_vis(item)
461
+ m_vis_str = f"{m_vis} " if m_vis else ""
462
+ m_name_node = item.child_by_field_name("name")
463
+ if m_name_node:
464
+ m_name = _text(m_name_node)
465
+ m = _mk_symbol(
466
+ "method", m_name, item, f"`{m_vis_str}fn {m_name}()`", _rust_doc(item))
467
+ _annotate_logic(m, item, "rust")
468
+ impl["children"].append(m)
469
+ symbols.append(impl)
470
+
471
+ return symbols
472
+
473
+
474
+ def extract_symbols(file_path: Path, content: bytes | None = None) -> dict[str, Any]:
475
+ """Parse file content using the appropriate Tree-sitter language grammar and extract code symbols.
476
+
477
+ Args:
478
+ file_path: Path to the target source file (used for extension detection and reading content if not given).
479
+ content: Optional raw bytes content of the file.
480
+
481
+ Returns:
482
+ Dictionary containing extracted symbols or an error message if parsing fails.
483
+ """
484
+ ext = file_path.suffix.lower()
485
+ lang_type = SUPPORTED_EXTENSIONS.get(ext)
486
+ if not lang_type:
487
+ return {"symbols": []}
488
+
489
+ if content is None:
490
+ try:
491
+ content = file_path.read_bytes()
492
+ except Exception as e:
493
+ return {"error": str(e), "symbols": []}
494
+
495
+ try:
496
+ if lang_type == "python":
497
+ tree = _PARSER_PYTHON.parse(content)
498
+ symbols = _extract_python(tree.root_node)
499
+ elif lang_type == "typescript":
500
+ tree = _PARSER_TS.parse(content)
501
+ symbols = _extract_ts_js(tree.root_node)
502
+ elif lang_type == "tsx":
503
+ tree = _PARSER_TSX.parse(content)
504
+ symbols = _extract_ts_js(tree.root_node)
505
+ elif lang_type == "javascript":
506
+ tree = _PARSER_JS.parse(content)
507
+ symbols = _extract_ts_js(tree.root_node)
508
+ elif lang_type == "go":
509
+ tree = _PARSER_GO.parse(content)
510
+ symbols = _extract_go(tree.root_node)
511
+ elif lang_type == "rust":
512
+ tree = _PARSER_RUST.parse(content)
513
+ symbols = _extract_rust(tree.root_node)
514
+ else:
515
+ symbols = []
516
+ except Exception as e:
517
+ return {"error": str(e), "symbols": []}
518
+
519
+ return {"symbols": symbols}
omphalos/render.py ADDED
@@ -0,0 +1,69 @@
1
+ # omphalos/render.py
2
+ """Render structured symbol data (from parser/cache) into target output formats."""
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def render_markdown(files_data: dict[str, dict[str, Any]]) -> str:
8
+ """Render structured symbol data into Markdown format for LLM context or human reading.
9
+
10
+ Args:
11
+ files_data: Symbol mapping keyed by relative file paths.
12
+
13
+ Returns:
14
+ Formatted Markdown index grouping symbols under file headers.
15
+ """
16
+ lines = ["# Codebase Symbol Index\n"]
17
+ for path, data in files_data.items():
18
+ symbols = data.get("symbols", [])
19
+ if not symbols:
20
+ continue
21
+ lines.append(f"## {path}")
22
+ _render_md_symbols(symbols, depth=0, lines=lines)
23
+ lines.append("")
24
+ return "\n".join(lines)
25
+
26
+
27
+ def _render_md_symbols(symbols: list[dict[str, Any]], depth: int, lines: list[str]):
28
+ """Recursively render symbols as nested Markdown bullet points.
29
+
30
+ Includes label, @line number, docstring summary, and nested child symbols
31
+ (such as methods within classes or trait implementations).
32
+ """
33
+ for sym in symbols:
34
+ text = f"{' ' * depth}- {sym['label']}"
35
+ if sym.get("line"):
36
+ text += f" @{sym['line']}"
37
+ if sym.get("doc"):
38
+ text += f": {sym['doc']}"
39
+ # Internal logic detail: deduplicated call targets and raised exceptions (inline, keeps tree flat)
40
+ detail = []
41
+ if sym.get("calls"):
42
+ detail.append("calls: " + ", ".join(f"`{c}`" for c in sym["calls"]))
43
+ if sym.get("raises"):
44
+ detail.append("raises: " + ", ".join(f"`{r}`" for r in sym["raises"]))
45
+ if detail:
46
+ text += f" ({'; '.join(detail)})"
47
+ lines.append(text)
48
+ _render_md_symbols(sym.get("children", []), depth + 1, lines)
49
+
50
+
51
+ def render_json(files_data: dict[str, dict[str, Any]]) -> str:
52
+ """Render structured symbol data into a JSON string for programmatic consumption.
53
+
54
+ Args:
55
+ files_data: Symbol mapping keyed by relative file paths.
56
+
57
+ Returns:
58
+ Indented JSON string conforming to the Omphalos schema format.
59
+ """
60
+ payload = {
61
+ "tool": "omphalos",
62
+ "format_version": 1,
63
+ "files": {
64
+ path: {"symbols": data.get("symbols", [])}
65
+ for path, data in files_data.items()
66
+ if data.get("symbols")
67
+ },
68
+ }
69
+ return json.dumps(payload, indent=2, ensure_ascii=False)
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.3
2
+ Name: omphalos
3
+ Version: 1.1.1
4
+ Summary: Codebase symbol index for LLMs
5
+ Keywords: llm,indexer,tree-sitter,ast,codebase,symbols,agent
6
+ Author: snui1s
7
+ Author-email: snui1s <snailsqz@gmail.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Dist: typer>=0.12.0
21
+ Requires-Dist: pathspec>=1.1
22
+ Requires-Dist: tree-sitter>=0.24.0
23
+ Requires-Dist: tree-sitter-python>=0.25.0
24
+ Requires-Dist: tree-sitter-typescript>=0.23.0
25
+ Requires-Dist: tree-sitter-javascript>=0.25.0
26
+ Requires-Dist: tree-sitter-go>=0.25.0
27
+ Requires-Dist: tree-sitter-rust>=0.24.0
28
+ Requires-Python: >=3.10
29
+ Project-URL: Homepage, https://github.com/snui1s/Omphalos
30
+ Project-URL: Repository, https://github.com/snui1s/Omphalos
31
+ Project-URL: Issues, https://github.com/snui1s/Omphalos/issues
32
+ Project-URL: Changelog, https://github.com/snui1s/Omphalos/releases
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Omphalos
36
+
37
+ [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
38
+ [![CI](https://github.com/snui1s/Omphalos/actions/workflows/ci.yml/badge.svg)](https://github.com/snui1s/Omphalos/actions/workflows/ci.yml)
39
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
41
+
42
+ > Codebase symbol indexer for LLMs.
43
+
44
+ Omphalos generates a compact, high-signal symbol map (`INDEX.md`) of your repository. It provides large language models and developers with an instant architectural overview and precise symbol locations—minimizing token usage and eliminating the need to read entire source files just to find declarations.
45
+
46
+ ## Table of Contents
47
+
48
+ - [Omphalos](#omphalos)
49
+ - [Table of Contents](#table-of-contents)
50
+ - [Background](#background)
51
+ - [Install](#install)
52
+ - [Usage](#usage)
53
+ - [Quick Start](#quick-start)
54
+ - [CLI Reference](#cli-reference)
55
+ - [LLM / Agent Integration](#llm--agent-integration)
56
+ - [Development](#development)
57
+ - [Related Efforts](#related-efforts)
58
+ - [Maintainers](#maintainers)
59
+ - [Contributing](#contributing)
60
+ - [License](#license)
61
+
62
+ ## Background
63
+
64
+ When providing a full codebase as context to Large Language Models (LLMs) or AI coding agents, feeding entire file contents quickly exhausts the model's context window, degrades reasoning performance, and incurs high token costs.
65
+
66
+ In practice, an agent rarely needs the implementation bodies of every file at once; it only needs an accurate structural index: what functions, classes, interfaces, and methods exist, where they are defined, and what their docstrings say.
67
+
68
+ Omphalos was created to solve this problem:
69
+
70
+ 1. **Multi-language AST Parsing**: Uses Tree-sitter for deterministic parsing across Python, TypeScript/JavaScript, Go, and Rust.
71
+ 2. **Precise Line Numbers (`@<line>`)**: Symbols indicate exact declaration line numbers so agents can inspect or slice specific ranges on demand.
72
+ 3. **Internal Logic Hints**: Function-like symbols list their deduplicated call targets (`calls`) and raised exception types (`raises`), giving agents a control-flow preview without opening the file.
73
+ 4. **Atomic Incremental Cache**: Tracks SHA-256 content hashes to re-parse only changed files, guaranteeing rapid re-indexes.
74
+ 5. **CI/CD Integration**: Supports `--check` mode to ensure codebase indexes stay synchronized with changes.
75
+
76
+ ## Install
77
+
78
+ ### Using [uv](https://github.com/astral-sh/uv) (Recommended)
79
+
80
+ Run directly without installing:
81
+
82
+ ```sh
83
+ $ uvx --from git+https://github.com/snui1s/Omphalos.git omph scan
84
+ ```
85
+
86
+ Or install as a standalone CLI tool:
87
+
88
+ ```sh
89
+ $ uv tool install git+https://github.com/snui1s/Omphalos.git
90
+ ```
91
+
92
+ ### Using pip
93
+
94
+ ```sh
95
+ $ pip install git+https://github.com/snui1s/Omphalos.git
96
+ ```
97
+
98
+ ### Using npm / npx
99
+
100
+ Run directly via npx:
101
+
102
+ ```sh
103
+ $ npx omphalos scan
104
+ ```
105
+
106
+ Or install globally:
107
+
108
+ ```sh
109
+ $ npm install -g omphalos
110
+ ```
111
+
112
+ ### From Source
113
+
114
+ ```sh
115
+ $ git clone https://github.com/snui1s/Omphalos.git
116
+ $ cd Omphalos
117
+ $ uv sync
118
+ ```
119
+
120
+ ## Usage
121
+
122
+ ### Quick Start
123
+
124
+ 1. **Initialize Ignore File** (creates default `.omphignore`):
125
+
126
+ ```sh
127
+ $ omph init
128
+ ```
129
+
130
+ 2. **Generate the Symbol Index**:
131
+
132
+ ```sh
133
+ $ omph scan
134
+ ```
135
+
136
+ This generates `INDEX.md` in the root directory:
137
+
138
+ ```markdown
139
+ # Codebase Symbol Index
140
+
141
+ ## src/omphalos/cache.py
142
+
143
+ - `calculate_sha256()` @15: Calculate the SHA-256 checksum of raw file bytes for change detection. (calls: `hexdigest`, `hashlib.sha256`)
144
+ - `save_cache()` @38: Save symbol cache to .omphcache atomically. (calls: `tempfile.mkstemp`, `os.fdopen`, `json.dump`, `os.replace`, `os.unlink`)
145
+
146
+ ## src/omphalos/cli.py
147
+
148
+ - `_version_callback()` @33: Callback for --version flag to print the version and exit immediately. (calls: `typer.echo`, `_package_version`; raises: `typer.Exit`)
149
+
150
+ ## tests/fixtures/sample.ts
151
+
152
+ - export interface `UserProfile` @1
153
+ - export type `AuthToken` @6
154
+ - export function `verifyToken()` @8
155
+ - export class `SessionManager` @18
156
+ - `createSession()` @19
157
+ ```
158
+
159
+ 3. **Instruct AI Agents via `AGENTS.md`**:
160
+
161
+ Create or add to `AGENTS.md` (or `CLAUDE.md` / `.cursorrules`) at the root of your repository so AI agents read `INDEX.md` before exploring code:
162
+
163
+ ```markdown
164
+ # Agent Guidelines
165
+
166
+ ## Codebase Navigation & Exploration
167
+
168
+ Before searching, running grep, or opening entire source files across the codebase:
169
+
170
+ 1. **Always read [`INDEX.md`](INDEX.md) first.** It provides an instant symbol map of the entire repository with exact line numbers.
171
+ 2. Use the symbols, line locations (`@<line>`), and internal logic hints (`calls:`, `raises:`) in [`INDEX.md`](INDEX.md) to pinpoint definitions directly.
172
+ 3. Inspect or slice-read only the specific line ranges or files needed for the task, rather than loading entire files into context.
173
+ ```
174
+
175
+ ### CLI Reference
176
+
177
+ ```sh
178
+ $ omph scan [DIRECTORY] [OPTIONS]
179
+ ```
180
+
181
+ The binary is installed as `omph`; the full name `omphalos` is also available as an alias.
182
+
183
+ | Option | Flag | Description |
184
+ | ---------------- | ------------------ | ------------------------------------------------------------------------- |
185
+ | `--output`, `-o` | `PATH` | Custom path for index output (default: `INDEX.md`). |
186
+ | `--format` | `markdown \| json` | Output format (default: `markdown`). |
187
+ | `--check` | flag | Verify index is up to date without writing. Exits with code `1` if stale. |
188
+ | `--git-only` | flag | Scan only files tracked by Git (`git ls-files`). |
189
+ | `--no-cache` | flag | Bypass cache and re-parse all files. |
190
+ | `--version` | flag | Show version and exit. |
191
+ | `--help` | flag | Show help message. |
192
+
193
+ ### LLM / Agent Integration
194
+
195
+ - **Automatic Agent Discovery via `AGENTS.md`**: Coding agents (Antigravity, Cursor, Claude Code, GitHub Copilot) automatically load `AGENTS.md` at conversation start. By telling the agent to consult `INDEX.md` first, the agent pinpoints definitions immediately without burning tokens on repository-wide grep searches.
196
+ - **Surgical Inspection**: Because each symbol includes `@<line>`, an LLM can request precise lines via tools (e.g. `head -n 50` or view tool slice) rather than ingesting entire files.
197
+ - **Machine-Readable Formats**: Use `--format json` to integrate with custom RAG systems or agent tools:
198
+
199
+ ```sh
200
+ $ omph scan --format json -o index.json
201
+ ```
202
+
203
+ ## Development
204
+
205
+ ```sh
206
+ # Install development dependencies
207
+ $ uv sync --dev
208
+
209
+ # Run tests
210
+ $ uv run pytest
211
+
212
+ # Run linter and type checker
213
+ $ uv run ruff check .
214
+ $ uv run mypy src
215
+ ```
216
+
217
+ ## Related Efforts
218
+
219
+ - [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) - Fast, incremental parsing system used under the hood.
220
+ - [Universal Ctags](https://ctags.io/) - Traditional code indexing system.
221
+ - [Repomix](https://github.com/yamadashy/repomix) - Repository packager for AI context.
222
+
223
+ ## Contributing
224
+
225
+ Feel free to dive in! [Open an issue](https://github.com/snui1s/Omphalos/issues/new) or submit a Pull Request.
226
+
227
+ Omphalos follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) Code of Conduct.
228
+
229
+ ## License
230
+
231
+ [MIT](LICENSE) © 2026 snui1s
@@ -0,0 +1,10 @@
1
+ omphalos/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ omphalos/cache.py,sha256=iZ9qNQFST5Yl6UDkw94WZiaEX5tqpFZufNfgGqDjkAY,2059
3
+ omphalos/cli.py,sha256=0h0gxowXlBifbWhmBKjbC6DaRaQwdpPna5KAsKvmBcs,5913
4
+ omphalos/ignore.py,sha256=qzxNjoBgbW7huxkEA7AlPHn1jmlAaR14ekWmtoz6_eU,4792
5
+ omphalos/parser.py,sha256=CUjoUBWhNPfi6Z4x1jVZiHig88jedfsrt_Ry9N-yn9c,21295
6
+ omphalos/render.py,sha256=bwRZbpO6DdNbLkDWruL84aOy_R-72DMRaM9iIdp2WgQ,2437
7
+ omphalos-1.1.1.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
8
+ omphalos-1.1.1.dist-info/entry_points.txt,sha256=i2JZB6H1rM4_ujOCeCyRjfkmkG6nx4PNnQ917-cseJs,71
9
+ omphalos-1.1.1.dist-info/METADATA,sha256=61lrsgjZiHEqyKKrX7DUHECmR7HPvyyXm4sX7aq5Pyc,8998
10
+ omphalos-1.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ omph = omphalos.cli:app
3
+ omphalos = omphalos.cli:app
4
+