codebase-navigator 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ """Unix domain socket IPC server and client for fast semantic querying via cn watch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import socket
8
+ import socketserver
9
+ import threading
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from .index import VectorIndex
14
+
15
+
16
+ class _IPCRequestHandler(socketserver.StreamRequestHandler):
17
+ """Handles single client connection requests over Unix Domain Socket."""
18
+
19
+ server: _IPCUnixStreamServer
20
+
21
+ def handle(self):
22
+ for line in self.rfile:
23
+ line = line.strip()
24
+ if not line:
25
+ continue
26
+ try:
27
+ req = json.loads(line.decode("utf-8"))
28
+ action = req.get("action")
29
+ if action == "ping":
30
+ resp = {"status": "ok", "pong": True}
31
+ elif action == "search":
32
+ query = req.get("query", "")
33
+ limit = int(req.get("limit", 5))
34
+ doc_type = req.get("type", "all")
35
+ with self.server.lock:
36
+ results = self.server.index.search(query, limit=limit, doc_type=doc_type)
37
+ resp = {"status": "ok", "results": results}
38
+ elif action == "status":
39
+ with self.server.lock:
40
+ meta = self.server.index.load_meta()
41
+ chunk_count = sum(m.get("chunks", 0) for m in meta.values())
42
+ resp = {
43
+ "status": "ok",
44
+ "files_count": len(meta),
45
+ "chunk_count": chunk_count,
46
+ "cache_dir": str(self.server.index.cache_dir),
47
+ }
48
+ else:
49
+ resp = {"status": "error", "error": f"Unknown action: {action}"}
50
+ except Exception as e:
51
+ resp = {"status": "error", "error": str(e)}
52
+
53
+ response_bytes = json.dumps(resp).encode("utf-8") + b"\n"
54
+ self.wfile.write(response_bytes)
55
+ self.wfile.flush()
56
+
57
+
58
+ class _IPCUnixStreamServer(socketserver.ThreadingUnixStreamServer):
59
+ daemon_threads = True
60
+ allow_reuse_address = True
61
+
62
+ def __init__(self, server_address: str, RequestHandlerClass, index: VectorIndex, lock: threading.Lock):
63
+ self.index = index
64
+ self.lock = lock
65
+ super().__init__(server_address, RequestHandlerClass)
66
+
67
+
68
+ class IPCServer:
69
+ """Unix Domain Socket server hosting in-memory index for fast search queries."""
70
+
71
+ def __init__(self, socket_path: Path, index: VectorIndex, lock: threading.Lock | None = None):
72
+ self.socket_path = socket_path
73
+ self.index = index
74
+ self.lock = lock or threading.Lock()
75
+ self._server: _IPCUnixStreamServer | None = None
76
+ self._thread: threading.Thread | None = None
77
+
78
+ def start(self):
79
+ """Start socket server in a background thread.
80
+
81
+ Raises RuntimeError if another active daemon is already listening on this socket.
82
+ Cleans up stale socket files from prior crashes automatically.
83
+ """
84
+ self.socket_path.parent.mkdir(parents=True, exist_ok=True)
85
+ if self.socket_path.exists():
86
+ # Check if another process is actively listening on this socket
87
+ status = ping_socket(self.socket_path, timeout=0.5)
88
+ if status is not None:
89
+ raise RuntimeError(
90
+ f"Another cn watch instance is already running on {self.socket_path}"
91
+ )
92
+ # Socket file exists but no process is listening -> stale socket from prior crash
93
+ try:
94
+ self.socket_path.unlink()
95
+ except OSError:
96
+ pass
97
+
98
+ self._server = _IPCUnixStreamServer(
99
+ str(self.socket_path),
100
+ _IPCRequestHandler,
101
+ index=self.index,
102
+ lock=self.lock,
103
+ )
104
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
105
+ self._thread.start()
106
+
107
+ def stop(self):
108
+ """Stop socket server and clean up socket file."""
109
+ if self._server:
110
+ try:
111
+ self._server.shutdown()
112
+ self._server.server_close()
113
+ except Exception:
114
+ pass
115
+ self._server = None
116
+ if self.socket_path.exists():
117
+ try:
118
+ self.socket_path.unlink()
119
+ except OSError:
120
+ pass
121
+
122
+
123
+ def query_socket(
124
+ socket_path: Path,
125
+ query: str,
126
+ limit: int = 5,
127
+ doc_type: str = "all",
128
+ timeout: float = 3.0,
129
+ ) -> list[dict[str, Any]] | None:
130
+ """Query the running cn watch daemon via Unix Domain Socket.
131
+
132
+ Returns search results if successful, or None if socket is unavailable/unresponsive.
133
+ Automatically unlinks stale socket files from dead daemons.
134
+ """
135
+ if not socket_path.exists():
136
+ return None
137
+
138
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
139
+ sock.settimeout(timeout)
140
+ try:
141
+ sock.connect(str(socket_path))
142
+ req = {
143
+ "action": "search",
144
+ "query": query,
145
+ "limit": limit,
146
+ "type": doc_type,
147
+ }
148
+ payload = json.dumps(req).encode("utf-8") + b"\n"
149
+ sock.sendall(payload)
150
+
151
+ # Read response line
152
+ buffer = b""
153
+ while b"\n" not in buffer:
154
+ chunk = sock.recv(4096)
155
+ if not chunk:
156
+ break
157
+ buffer += chunk
158
+
159
+ if not buffer:
160
+ return None
161
+
162
+ line = buffer.split(b"\n")[0]
163
+ resp = json.loads(line.decode("utf-8"))
164
+ if resp.get("status") == "ok":
165
+ return resp.get("results")
166
+ return None
167
+ except ConnectionRefusedError:
168
+ # Socket file exists but no process is listening -> stale socket from prior crash
169
+ try:
170
+ socket_path.unlink()
171
+ except OSError:
172
+ pass
173
+ return None
174
+ except (OSError, socket.error, json.JSONDecodeError, TimeoutError):
175
+ return None
176
+ finally:
177
+ sock.close()
178
+
179
+
180
+ def ping_socket(socket_path: Path, timeout: float = 0.5) -> dict[str, Any] | None:
181
+ """Check if cn watch daemon is active and return its status info.
182
+
183
+ Automatically unlinks stale socket files from dead daemons.
184
+ """
185
+ if not socket_path.exists():
186
+ return None
187
+
188
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
189
+ sock.settimeout(timeout)
190
+ try:
191
+ sock.connect(str(socket_path))
192
+ req = {"action": "status"}
193
+ payload = json.dumps(req).encode("utf-8") + b"\n"
194
+ sock.sendall(payload)
195
+
196
+ buffer = b""
197
+ while b"\n" not in buffer:
198
+ chunk = sock.recv(4096)
199
+ if not chunk:
200
+ break
201
+ buffer += chunk
202
+
203
+ if not buffer:
204
+ return None
205
+
206
+ line = buffer.split(b"\n")[0]
207
+ resp = json.loads(line.decode("utf-8"))
208
+ if resp.get("status") == "ok":
209
+ return resp
210
+ return None
211
+ except ConnectionRefusedError:
212
+ # Socket file exists but no process is listening -> stale socket from prior crash
213
+ try:
214
+ socket_path.unlink()
215
+ except OSError:
216
+ pass
217
+ return None
218
+ except (OSError, socket.error, json.JSONDecodeError, TimeoutError):
219
+ return None
220
+ finally:
221
+ sock.close()
@@ -0,0 +1,198 @@
1
+ """Git-aware ctags indexing and symbol lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ from typing import Any
10
+
11
+ from .config import CODE_EXTENSIONS, DOC_EXTENSIONS, IGNORE_DIR_NAMES
12
+
13
+
14
+ def get_available_files(folder: Path) -> tuple[list[Path], list[Path]]:
15
+ """Discover all Git-tracked and unignored source and documentation files."""
16
+ code_files: list[Path] = []
17
+ doc_files: list[Path] = []
18
+
19
+ # 1. Try Git-based discovery
20
+ try:
21
+ res = subprocess.run(
22
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
23
+ cwd=folder,
24
+ capture_output=True,
25
+ text=True,
26
+ check=True,
27
+ )
28
+ for line in res.stdout.splitlines():
29
+ fpath = folder / line.strip()
30
+ if fpath.is_file():
31
+ ext = fpath.suffix.lower()
32
+ if ext in CODE_EXTENSIONS:
33
+ code_files.append(fpath)
34
+ elif ext in DOC_EXTENSIONS:
35
+ doc_files.append(fpath)
36
+ return sorted(code_files), sorted(doc_files)
37
+ except Exception:
38
+ pass
39
+
40
+ # 2. Fallback to filesystem walk
41
+ for root, dirs, files in os.walk(folder):
42
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIR_NAMES and not d.startswith(".")]
43
+ for file in files:
44
+ if file.startswith("."):
45
+ continue
46
+ fpath = Path(root) / file
47
+ ext = fpath.suffix.lower()
48
+ if ext in CODE_EXTENSIONS:
49
+ code_files.append(fpath)
50
+ elif ext in DOC_EXTENSIONS:
51
+ doc_files.append(fpath)
52
+
53
+ return sorted(code_files), sorted(doc_files)
54
+
55
+
56
+ class TagsManager:
57
+ """Manages generation, updates, and symbol lookups from .tags files."""
58
+
59
+ def __init__(self, folder: Path):
60
+ self.folder = folder
61
+ self.tag_file = folder / ".tags"
62
+
63
+ def generate(self) -> tuple[bool, str]:
64
+ """Generate or regenerate .tags for all source files in the folder."""
65
+ code_files, _ = get_available_files(self.folder)
66
+ if not code_files:
67
+ return False, "No source files found to index"
68
+
69
+ rel_paths = []
70
+ for p in code_files:
71
+ try:
72
+ rel_paths.append(str(p.relative_to(self.folder)))
73
+ except ValueError:
74
+ rel_paths.append(str(p))
75
+
76
+ input_data = "\n".join(rel_paths) + "\n"
77
+ cmd = [
78
+ "ctags",
79
+ "-L", "-",
80
+ "-f", str(self.tag_file),
81
+ "--fields=+n+K",
82
+ "--sort=yes",
83
+ ]
84
+
85
+ try:
86
+ res = subprocess.run(
87
+ cmd,
88
+ input=input_data,
89
+ cwd=self.folder,
90
+ capture_output=True,
91
+ text=True,
92
+ check=False,
93
+ )
94
+ if res.returncode != 0:
95
+ return False, f"ctags error ({res.returncode}): {res.stderr.strip()}"
96
+ if self.tag_file.exists():
97
+ size_mb = self.tag_file.stat().st_size / (1024 * 1024)
98
+ return True, f"Indexed {len(code_files)} source files ({size_mb:.2f} MB)"
99
+ return False, "Tag file was not created"
100
+ except FileNotFoundError:
101
+ return False, "ctags binary not found on PATH"
102
+ except Exception as e:
103
+ return False, f"Error generating tags: {e}"
104
+
105
+ def find_tag_file(self) -> Path | None:
106
+ """Find .tags in folder or climb parent directories."""
107
+ curr = self.folder
108
+ for parent in [curr, *curr.parents]:
109
+ tf = parent / ".tags"
110
+ if tf.exists():
111
+ return tf
112
+ return self.tag_file if self.tag_file.exists() else None
113
+
114
+ def lookup_symbol(
115
+ self,
116
+ pattern: str,
117
+ exact: bool = False,
118
+ limit: int = 20,
119
+ ) -> list[dict[str, Any]]:
120
+ """Look up symbols matching a regex or exact string."""
121
+ tag_files: list[Path] = []
122
+ tf = self.find_tag_file()
123
+ if tf:
124
+ tag_files.append(tf)
125
+
126
+ # Also search child folders' .tags
127
+ for p in self.folder.rglob(".tags"):
128
+ if p not in tag_files:
129
+ tag_files.append(p)
130
+
131
+ if not tag_files:
132
+ return []
133
+
134
+ results: list[dict[str, Any]] = []
135
+ seen_keys: set[tuple[str, str, int]] = set()
136
+
137
+ for tag_file in tag_files:
138
+ try:
139
+ regex = re.compile(pattern if not exact else f"^{re.escape(pattern)}$", re.IGNORECASE)
140
+ with open(tag_file, "r", encoding="utf-8", errors="replace") as f:
141
+ for line in f:
142
+ if line.startswith("!_"):
143
+ continue
144
+ parts = line.split("\t")
145
+ if not parts:
146
+ continue
147
+ sym = parts[0]
148
+ if regex.search(sym):
149
+ parsed = self._parse_tag_line(line, tag_file.parent)
150
+ if parsed:
151
+ key = (parsed["symbol"], parsed["path"], parsed["line"])
152
+ if key not in seen_keys:
153
+ seen_keys.add(key)
154
+ results.append(parsed)
155
+ if len(results) >= limit:
156
+ return results
157
+ except Exception:
158
+ pass
159
+
160
+ return results
161
+
162
+ def _parse_tag_line(self, line: str, base_dir: Path) -> dict[str, Any] | None:
163
+ if not line or line.startswith("!_"):
164
+ return None
165
+ parts = line.rstrip("\r\n").split("\t")
166
+ if len(parts) < 3:
167
+ return None
168
+ sym = parts[0]
169
+ fpath = parts[1]
170
+ pattern_or_line = parts[2]
171
+
172
+ kind = "symbol"
173
+ line_no = 1
174
+ for field in parts[3:]:
175
+ if field.startswith("line:"):
176
+ try:
177
+ line_no = int(field[5:])
178
+ except ValueError:
179
+ pass
180
+ elif field.startswith("kind:"):
181
+ kind = field[5:]
182
+ elif len(field) == 1:
183
+ kind = field
184
+
185
+ abs_p = (base_dir / fpath).resolve()
186
+ try:
187
+ rel_p = str(abs_p.relative_to(self.folder))
188
+ except ValueError:
189
+ rel_p = fpath
190
+
191
+ return {
192
+ "symbol": sym,
193
+ "path": rel_p,
194
+ "abs_path": str(abs_p),
195
+ "line": line_no,
196
+ "kind": kind,
197
+ "preview": pattern_or_line.strip("/^$"),
198
+ }
@@ -0,0 +1,134 @@
1
+ """Filesystem watcher for live ctags and LanceDB vector re-indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import threading
7
+ import time
8
+
9
+ from watchfiles import Change, DefaultFilter, watch
10
+
11
+ from .config import CODE_EXTENSIONS, DOC_EXTENSIONS, IGNORE_DIR_NAMES, get_socket_path
12
+ from .index import VectorIndex
13
+ from .ipc import IPCServer, ping_socket
14
+ from .tags import TagsManager
15
+
16
+
17
+ class SourceFilter(DefaultFilter):
18
+ """Filter that includes only recognized source/doc extensions and ignores build/git artifacts."""
19
+
20
+ def __init__(self, folder: Path):
21
+ super().__init__(
22
+ ignore_dirs=tuple(IGNORE_DIR_NAMES),
23
+ ignore_entity_patterns=(r"^\..*", r".*\.tags$", r".*tags$", r".*\.sock$"),
24
+ )
25
+ self.folder = folder
26
+
27
+ def __call__(self, change: Change, path: str) -> bool:
28
+ if not super().__call__(change, path):
29
+ return False
30
+
31
+ p = Path(path)
32
+ if p.name.startswith("."):
33
+ return False
34
+
35
+ for part in p.parts:
36
+ if part in IGNORE_DIR_NAMES or part.startswith("."):
37
+ return False
38
+
39
+ ext = p.suffix.lower()
40
+ return ext in CODE_EXTENSIONS or ext in DOC_EXTENSIONS
41
+
42
+
43
+ class DirectoryWatcher:
44
+ """Watches folder, serves IPC Unix socket, and keeps .tags and LanceDB vector embeddings up to date."""
45
+
46
+ def __init__(
47
+ self,
48
+ folder: Path,
49
+ debounce_ms: int = 1000,
50
+ custom_index_dir: str | None = None,
51
+ ):
52
+ self.folder = folder
53
+ self.debounce_ms = debounce_ms
54
+ self.tags_mgr = TagsManager(folder)
55
+ self.index = VectorIndex(folder, custom_index_dir)
56
+ self.socket_path = get_socket_path(folder, custom_index_dir)
57
+ self.index_lock = threading.Lock()
58
+ self.ipc_server = IPCServer(self.socket_path, self.index, lock=self.index_lock)
59
+
60
+ def start(self):
61
+ """Run blocking live watcher loop and IPC server."""
62
+ # Early check: if an active instance is already watching, report and exit gracefully
63
+ if self.socket_path.exists():
64
+ active = ping_socket(self.socket_path, timeout=0.5)
65
+ if active is not None:
66
+ print(f"āš ļø Another cn watch instance is already running for: {self.folder}")
67
+ print(f" Active socket: {self.socket_path}")
68
+ return
69
+
70
+ print(f"šŸš€ Starting cn watch for: {self.folder}")
71
+ print(" Performing initial sync...")
72
+ ok, msg = self.tags_mgr.generate()
73
+ print(f" .tags: {msg}")
74
+
75
+ with self.index_lock:
76
+ u_files, u_chunks, p_files = self.index.sync()
77
+ print(f" LanceDB: {u_files} files updated ({u_chunks} chunks), {p_files} pruned.")
78
+ print(f" Index location: {self.index.cache_dir}")
79
+
80
+ self.ipc_server.start()
81
+ print(f" šŸ”Œ IPC Socket: {self.socket_path}")
82
+ print("šŸ‘€ Watching for file changes (Ctrl+C to stop)...\n")
83
+
84
+ source_filter = SourceFilter(self.folder)
85
+
86
+ try:
87
+ for changes in watch(
88
+ self.folder,
89
+ watch_filter=source_filter,
90
+ debounce=self.debounce_ms,
91
+ step=50,
92
+ ):
93
+ code_changed = False
94
+ doc_changed = False
95
+ affected_files: list[Path] = []
96
+
97
+ for change_type, change_path in changes:
98
+ p = Path(change_path)
99
+ ext = p.suffix.lower()
100
+ if ext in CODE_EXTENSIONS:
101
+ code_changed = True
102
+ affected_files.append(p)
103
+ elif ext in DOC_EXTENSIONS:
104
+ doc_changed = True
105
+ affected_files.append(p)
106
+
107
+ if not affected_files:
108
+ continue
109
+
110
+ t0 = time.time()
111
+ ts_str = time.strftime("%H:%M:%S")
112
+
113
+ # Update tags if source code changed
114
+ if code_changed:
115
+ ok, msg = self.tags_mgr.generate()
116
+ print(f"[{ts_str}] šŸ·ļø Tags updated: {msg}")
117
+
118
+ # Update LanceDB embeddings incrementally
119
+ total_chunks = 0
120
+ for fpath in affected_files:
121
+ try:
122
+ with self.index_lock:
123
+ n_chunks = self.index.update_single_file(fpath)
124
+ total_chunks += n_chunks
125
+ except Exception as e:
126
+ print(f"[{ts_str}] āš ļø Error updating embeddings for {fpath.name}: {e}")
127
+
128
+ dt = (time.time() - t0) * 1000
129
+ print(f"[{ts_str}] ⚔ Synced {len(affected_files)} file(s) ({total_chunks} chunks) in {dt:.0f}ms")
130
+
131
+ except KeyboardInterrupt:
132
+ print("\nšŸ‘‹ cn watch stopped.")
133
+ finally:
134
+ self.ipc_server.stop()
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: codebase-navigator
3
+ Version: 0.1.0
4
+ Summary: Fast Git-aware ctags indexing, live watchers, and LanceDB semantic search for developers
5
+ Author: Nigel Choi
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: lancedb>=0.17.0
9
+ Requires-Dist: numpy>=1.24.0
10
+ Requires-Dist: pyarrow>=14.0.0
11
+ Requires-Dist: sentence-transformers>=3.0.0
12
+ Requires-Dist: torch
13
+ Requires-Dist: watchfiles>=0.24.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # codebase-navigator
17
+
18
+ Developer tools for ultra-fast codebase navigation, Git-aware ctags indexing, live watchers, and LanceDB semantic search.
19
+
20
+ ## Quick Start
21
+
22
+ ### Using nix
23
+
24
+ Run `cn` instantly without installing:
25
+
26
+ ```bash
27
+ # Run directly from GitHub
28
+ nix run github:9gel/codebase-navigator -- sync
29
+
30
+ # Run help or any command
31
+ nix run github:9gel/codebase-navigator -- --help
32
+ nix run github:9gel/codebase-navigator -- search "authentication flow"
33
+ ```
34
+
35
+ ### Using uvx
36
+
37
+ You can run `cn` using `uvx` (the tool runner from [uv](https://docs.astral.sh/uv/)):
38
+
39
+ ```bash
40
+ # Directly from the Git repository:
41
+ uvx --from git+https://github.com/9gel/codebase-navigator.git cn --help
42
+
43
+ # Once published to PyPI:
44
+ uvx codebase-navigator --help
45
+ ```
46
+
47
+ > **Note:** `cn tags` requires `universal-ctags` and `git` to be installed on your system.
48
+
49
+ ## Features
50
+
51
+ - šŸ·ļø **Git-Aware `.tags` Generation**: Uses `universal-ctags` to index genuine source code while completely ignoring huge data dumps, JSON caches, `.git`, `node_modules`, and build artifacts.
52
+ - 🧠 **LanceDB Semantic & Hybrid Search**: Vector search powered by `sentence-transformers/all-MiniLM-L6-v2` with hybrid phrase/title match boosting for markdown documentation, glossary terms, and code comments.
53
+ - ⚔ **Strict Offline Mode**: Runs 100% locally from disk cache with zero HuggingFace network requests or unauthenticated token warnings.
54
+ - šŸ‘€ **Live File Watcher**: Automatically re-indexes `.tags` and incrementally updates LanceDB embeddings on every save with sub-second debounce.
55
+ - šŸ”— **Clickable GitHub Markdown Links**: Returns results formatted as `[file:Lstart-Lend](file:///abs_path#Lstart-Lend)`.
56
+
57
+ ## Installation
58
+
59
+ ### Nix Flakes
60
+
61
+ Add `codebase-navigator` to your `flake.nix`:
62
+
63
+ ```nix
64
+ {
65
+ inputs = {
66
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
67
+ codebase-navigator = {
68
+ url = "github:9gel/codebase-navigator";
69
+ inputs.nixpkgs.follows = "nixpkgs";
70
+ };
71
+ };
72
+
73
+ outputs = { self, nixpkgs, codebase-navigator, ... }:
74
+ let
75
+ system = "x86_64-linux"; # or "aarch64-darwin", etc.
76
+ pkgs = nixpkgs.legacyPackages.''${system};
77
+ in
78
+ {
79
+ # Add to environment packages or devShells:
80
+ devShells.''${system}.default = pkgs.mkShell {
81
+ packages = [
82
+ codebase-navigator.packages.''${system}.default
83
+ ];
84
+ };
85
+ };
86
+ }
87
+ ```
88
+
89
+ Or install it to your user profile:
90
+
91
+ ```bash
92
+ nix profile install github:9gel/codebase-navigator
93
+ ```
94
+
95
+ ## CLI Commands
96
+
97
+ The unified `cn` command provides all indexing and search tools:
98
+
99
+ | Command | Purpose |
100
+ |---|---|
101
+ | `cn search <query> [folder]` | Semantic & hybrid search in markdown docs and code comments |
102
+ | `cn tags <symbol> [folder]` | Fast symbol definition lookup in `.tags` |
103
+ | `cn sync [folder] [--force]` | Synchronize `.tags` and LanceDB vector embeddings |
104
+ | `cn watch [folder]` | Live filesystem watcher for automatic re-indexing |
105
+ | `cn status [folder]` | Inspect index and `.tags` status |
106
+
107
+ ## Development
108
+
109
+ Requires Nix and `direnv`:
110
+
111
+ ```bash
112
+ direnv allow
113
+ uv run pytest
114
+ ```
@@ -0,0 +1,13 @@
1
+ codebase_navigator/__init__.py,sha256=e7d60tG-SgwP_05N7qBz7iyG2OSr0KyOUUaZ7P9SWLI,181
2
+ codebase_navigator/ask.py,sha256=1MKE-Hk92xw3kEXlDS_rSAZbfP7J0ona0i6EvozaXOw,14115
3
+ codebase_navigator/cli.py,sha256=UASkNs7RKf_6D89bgtGBy3mvIgureE6daQ9Bc55vlYs,9610
4
+ codebase_navigator/config.py,sha256=UkXpAvyWUgnlCHTYPgIhVBupNX1zHcdHJPuiGT21nIw,3629
5
+ codebase_navigator/extractor.py,sha256=kGy13gpDltum5n1rW_xuXF6Sw7lSoTYpD_-VxAOlJTM,11282
6
+ codebase_navigator/index.py,sha256=rVl9ZVsdCmMhSCIw3ttvZy5-11tzPWdtlUM7Wgnx7PQ,9582
7
+ codebase_navigator/ipc.py,sha256=EmlxaZ7KabIUMSXrONVfME3BB4sFJHXEYwVloa9iurM,7346
8
+ codebase_navigator/tags.py,sha256=HH-Vk3DLKIPFukAD1daagNUg8I4HS28MydG3dA-rzyg,6620
9
+ codebase_navigator/watcher.py,sha256=6ukf8qVUXqbeku51uykeg2SdNjFd8Z-F-jTEHDUgKYc,4882
10
+ codebase_navigator-0.1.0.dist-info/METADATA,sha256=HbreUNr6yu-mw5V2KGDqIP8X8Ujg1XYNLx-mcsWYjKA,3485
11
+ codebase_navigator-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ codebase_navigator-0.1.0.dist-info/entry_points.txt,sha256=K0dpqCSx-geADaO6s8rUvlZ76bVhqDA8YdBDUPX-_LA,51
13
+ codebase_navigator-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cn = codebase_navigator.cli:main