context-router-cli 0.2.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.
cli/commands/memory.py ADDED
@@ -0,0 +1,145 @@
1
+ """context-router memory command — manages durable session observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ memory_app = typer.Typer(help="Manage durable session memory (observations).")
11
+
12
+
13
+ def _open_store(project_root: str) -> tuple["ObservationStore", "Database"]:
14
+ """Open the database and return (ObservationStore, Database).
15
+
16
+ Caller must close the Database.
17
+ """
18
+ from core.orchestrator import _find_project_root
19
+ from memory.store import ObservationStore
20
+ from storage_sqlite.database import Database
21
+
22
+ root = Path(project_root) if project_root else _find_project_root(Path.cwd())
23
+ db_path = root / ".context-router" / "context-router.db"
24
+ if not db_path.exists():
25
+ typer.echo(
26
+ "No index found. Run 'context-router init' and 'context-router index' first.",
27
+ err=True,
28
+ )
29
+ raise typer.Exit(1)
30
+ db = Database(db_path)
31
+ db.initialize()
32
+ return ObservationStore(db), db
33
+
34
+
35
+ @memory_app.command("add")
36
+ def add(
37
+ from_session: Annotated[
38
+ str,
39
+ typer.Option("--from-session", help="Path to session JSON file."),
40
+ ],
41
+ project_root: Annotated[
42
+ str,
43
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
44
+ ] = "",
45
+ json_output: Annotated[bool, typer.Option("--json")] = False,
46
+ ) -> None:
47
+ """Add observations from a session JSON file to durable memory.
48
+
49
+ The session file must be a JSON object or array matching the Observation
50
+ schema (summary field is required; all others are optional).
51
+
52
+ Exit codes:
53
+ 0 — success
54
+ 1 — file not found or database not initialised
55
+ 2 — invalid JSON or schema
56
+ """
57
+ session_path = Path(from_session)
58
+ if not session_path.exists():
59
+ typer.echo(f"Session file not found: {from_session}", err=True)
60
+ raise typer.Exit(1)
61
+
62
+ store, db = _open_store(project_root)
63
+ try:
64
+ ids = store.add_from_session_json(session_path.read_text(encoding="utf-8"))
65
+ except ValueError as exc:
66
+ typer.echo(f"Error: {exc}", err=True)
67
+ raise typer.Exit(2)
68
+ finally:
69
+ db.close()
70
+
71
+ if json_output:
72
+ import json
73
+ typer.echo(json.dumps({"added": len(ids), "ids": ids}))
74
+ else:
75
+ typer.echo(f"Added {len(ids)} observation(s) to memory.")
76
+
77
+
78
+ @memory_app.command("search")
79
+ def search(
80
+ query: Annotated[str, typer.Argument(help="Search query.")],
81
+ project_root: Annotated[
82
+ str,
83
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
84
+ ] = "",
85
+ json_output: Annotated[bool, typer.Option("--json")] = False,
86
+ ) -> None:
87
+ """Search stored observations by keyword.
88
+
89
+ Exit codes:
90
+ 0 — success (even if no results)
91
+ 1 — database not initialised
92
+ """
93
+ store, db = _open_store(project_root)
94
+ try:
95
+ results = store.search(query)
96
+ finally:
97
+ db.close()
98
+
99
+ if json_output:
100
+ import json
101
+ typer.echo(json.dumps([r.model_dump(mode="json") for r in results], indent=2))
102
+ return
103
+
104
+ if not results:
105
+ typer.echo("No observations found.")
106
+ return
107
+
108
+ for obs in results:
109
+ typer.echo(f" [{obs.task_type or 'general'}] {obs.summary}")
110
+ if obs.fix_summary:
111
+ typer.echo(f" Fix: {obs.fix_summary}")
112
+
113
+
114
+ @memory_app.command("stale")
115
+ def stale(
116
+ project_root: Annotated[
117
+ str,
118
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
119
+ ] = "",
120
+ json_output: Annotated[bool, typer.Option("--json")] = False,
121
+ ) -> None:
122
+ """List observations that reference files no longer in the index.
123
+
124
+ Exit codes:
125
+ 0 — success (even if no stale observations)
126
+ 1 — database not initialised
127
+ """
128
+ store, db = _open_store(project_root)
129
+ try:
130
+ stale_obs = store.find_stale()
131
+ finally:
132
+ db.close()
133
+
134
+ if json_output:
135
+ import json
136
+ typer.echo(json.dumps([o.model_dump(mode="json") for o in stale_obs], indent=2))
137
+ return
138
+
139
+ if not stale_obs:
140
+ typer.echo("No stale observations found.")
141
+ return
142
+
143
+ typer.echo(f"{len(stale_obs)} stale observation(s) (files no longer indexed):")
144
+ for obs in stale_obs:
145
+ typer.echo(f" {obs.summary[:80]}")
cli/commands/pack.py ADDED
@@ -0,0 +1,110 @@
1
+ """context-router pack command — generates a ranked context pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ pack_app = typer.Typer(help="Generate a ranked context pack for a task.")
10
+
11
+ _VALID_MODES = ("review", "debug", "implement", "handover")
12
+
13
+
14
+ @pack_app.callback(invoke_without_command=True)
15
+ def pack(
16
+ mode: Annotated[
17
+ str,
18
+ typer.Option("--mode", "-m", help="Task mode: review|debug|implement|handover."),
19
+ ],
20
+ query: Annotated[
21
+ str,
22
+ typer.Option("--query", "-q", help="Free-text description of the task."),
23
+ ] = "",
24
+ json_output: Annotated[
25
+ bool,
26
+ typer.Option("--json", help="Output result as JSON."),
27
+ ] = False,
28
+ project_root: Annotated[
29
+ str,
30
+ typer.Option(
31
+ "--project-root",
32
+ help="Project root containing .context-router/. Auto-detected when omitted.",
33
+ ),
34
+ ] = "",
35
+ error_file: Annotated[
36
+ str,
37
+ typer.Option(
38
+ "--error-file",
39
+ "-e",
40
+ help="Path to error file (JUnit XML, stack trace, log). Used in debug mode.",
41
+ ),
42
+ ] = "",
43
+ ) -> None:
44
+ """Generate a context pack for the given task MODE.
45
+
46
+ Exit codes:
47
+ 0 — success
48
+ 1 — no index found (run 'context-router index' first)
49
+ 2 — invalid mode argument
50
+ """
51
+ if mode not in _VALID_MODES:
52
+ typer.echo(
53
+ f"Error: invalid mode '{mode}'. Must be one of: {', '.join(_VALID_MODES)}",
54
+ err=True,
55
+ )
56
+ raise typer.Exit(code=2)
57
+
58
+ from pathlib import Path
59
+
60
+ from core.orchestrator import Orchestrator # local import — keeps CLI startup fast
61
+
62
+ root = Path(project_root) if project_root else None
63
+ err_path = Path(error_file) if error_file else None
64
+ try:
65
+ result = Orchestrator(project_root=root).build_pack(mode, query, error_file=err_path)
66
+ except FileNotFoundError as exc:
67
+ typer.echo(str(exc), err=True)
68
+ raise typer.Exit(code=1)
69
+
70
+ if json_output:
71
+ typer.echo(result.model_dump_json(indent=2))
72
+ return
73
+
74
+ _print_pack(result)
75
+
76
+
77
+ def _print_pack(pack: object) -> None: # type: ignore[type-arg]
78
+ """Print a human-readable summary of a ContextPack."""
79
+ from contracts.models import ContextPack # local import
80
+
81
+ assert isinstance(pack, ContextPack)
82
+
83
+ typer.echo(
84
+ f"Mode: {pack.mode} | "
85
+ f"Items: {len(pack.selected_items)} | "
86
+ f"Tokens: {pack.total_est_tokens:,} / {pack.baseline_est_tokens:,} | "
87
+ f"Reduction: {pack.reduction_pct:.1f}%"
88
+ )
89
+ if pack.query:
90
+ typer.echo(f"Query: {pack.query}")
91
+ typer.echo("")
92
+
93
+ col_widths = (40, 16, 10, 8)
94
+ header = (
95
+ f"{'Title':<{col_widths[0]}} "
96
+ f"{'Source':<{col_widths[1]}} "
97
+ f"{'Confidence':>{col_widths[2]}} "
98
+ f"{'Tokens':>{col_widths[3]}}"
99
+ )
100
+ typer.echo(header)
101
+ typer.echo("-" * (sum(col_widths) + 6))
102
+
103
+ for item in pack.selected_items:
104
+ title = item.title[: col_widths[0] - 1] if len(item.title) >= col_widths[0] else item.title
105
+ typer.echo(
106
+ f"{title:<{col_widths[0]}} "
107
+ f"{item.source_type:<{col_widths[1]}} "
108
+ f"{item.confidence:>{col_widths[2]}.2f} "
109
+ f"{item.est_tokens:>{col_widths[3]},}"
110
+ )
cli/commands/watch.py ADDED
@@ -0,0 +1,126 @@
1
+ """context-router watch command — watches for file changes and re-indexes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from contracts.config import load_config
13
+ from core.plugin_loader import PluginLoader
14
+ from graph_index.indexer import Indexer
15
+ from graph_index.watcher import IndexWatcher
16
+ from storage_sqlite.database import Database
17
+
18
+ watch_app = typer.Typer(help="Watch for file changes and incrementally re-index.")
19
+
20
+
21
+ @watch_app.callback(invoke_without_command=True)
22
+ def watch(
23
+ project_root: Annotated[
24
+ Path,
25
+ typer.Option(
26
+ "--project-root",
27
+ "-p",
28
+ help="Root of the project to watch. Defaults to current directory.",
29
+ ),
30
+ ] = Path("."),
31
+ repo_name: Annotated[
32
+ str,
33
+ typer.Option("--repo", help="Logical repository name stored with symbols."),
34
+ ] = "default",
35
+ json_output: Annotated[
36
+ bool,
37
+ typer.Option("--json", help="Output initial index result as JSON."),
38
+ ] = False,
39
+ ) -> None:
40
+ """Watch PROJECT_ROOT for file changes and trigger incremental re-indexing.
41
+
42
+ Performs a full index on startup, then monitors the directory tree for
43
+ changes. Modified or created files are re-indexed; deleted files are
44
+ removed from the database.
45
+
46
+ Press Ctrl-C to stop watching.
47
+
48
+ Exit codes:
49
+ 0 — stopped cleanly (Ctrl-C)
50
+ 1 — configuration / setup error
51
+ 2 — unexpected internal error
52
+ """
53
+ try:
54
+ project_root = project_root.resolve()
55
+ config_dir = project_root / ".context-router"
56
+
57
+ try:
58
+ config = load_config(project_root)
59
+ except Exception as exc: # noqa: BLE001
60
+ _err(f"Failed to load config: {exc}", json_output, exit_code=1)
61
+ return
62
+
63
+ db_path = config_dir / "context-router.db"
64
+ if not db_path.exists():
65
+ _err(
66
+ f"Database not found at {db_path}. Run 'context-router init' first.",
67
+ json_output,
68
+ exit_code=1,
69
+ )
70
+ return
71
+
72
+ db = Database(db_path)
73
+ db.initialize()
74
+
75
+ try:
76
+ plugin_loader = PluginLoader()
77
+ plugin_loader.discover()
78
+
79
+ indexer = Indexer(db, plugin_loader, config, repo_name)
80
+
81
+ # Full index on startup
82
+ typer.echo(f"Indexing {project_root} ...", err=True)
83
+ result = indexer.run(project_root)
84
+
85
+ if json_output:
86
+ typer.echo(
87
+ json.dumps(
88
+ {
89
+ "files_scanned": result.files_scanned,
90
+ "symbols_written": result.symbols_written,
91
+ "edges_written": result.edges_written,
92
+ "duration_seconds": round(result.duration_seconds, 3),
93
+ "errors": result.errors,
94
+ }
95
+ )
96
+ )
97
+ else:
98
+ typer.echo(
99
+ f"Initial index: {result.files_scanned} files, "
100
+ f"{result.symbols_written} symbols ({result.duration_seconds:.2f}s)",
101
+ err=True,
102
+ )
103
+
104
+ typer.echo(f"Watching {project_root} for changes ... (Ctrl-C to stop)", err=True)
105
+
106
+ watcher = IndexWatcher(indexer, project_root, config)
107
+ watcher.start() # Blocks until KeyboardInterrupt
108
+
109
+ finally:
110
+ db.close()
111
+
112
+ except typer.Exit:
113
+ raise
114
+ except KeyboardInterrupt:
115
+ raise typer.Exit(code=0)
116
+ except Exception as exc: # noqa: BLE001
117
+ _err(f"Unexpected error: {exc}", json_output, exit_code=2)
118
+
119
+
120
+ def _err(message: str, json_output: bool, exit_code: int) -> None:
121
+ """Print an error to stderr and exit with the given code."""
122
+ if json_output:
123
+ typer.echo(json.dumps({"status": "error", "message": message}), err=True)
124
+ else:
125
+ typer.echo(f"Error: {message}", err=True)
126
+ raise typer.Exit(code=exit_code)
@@ -0,0 +1,294 @@
1
+ """context-router workspace command — manages multi-repo workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ workspace_app = typer.Typer(help="Manage multi-repo workspaces.")
12
+ repo_app = typer.Typer(help="Manage repos in the workspace.")
13
+ link_app = typer.Typer(help="Manage cross-repo links.")
14
+ workspace_app.add_typer(repo_app, name="repo")
15
+ workspace_app.add_typer(link_app, name="link")
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def _load_or_die(root: Path):
23
+ """Load workspace or exit with code 1 if missing."""
24
+ from workspace import WorkspaceLoader
25
+ ws = WorkspaceLoader.load(root)
26
+ if ws is None:
27
+ typer.echo(
28
+ "No workspace.yaml found. Run 'context-router workspace init' first.",
29
+ err=True,
30
+ )
31
+ raise typer.Exit(1)
32
+ return ws
33
+
34
+
35
+ def _save(root: Path, ws) -> None:
36
+ from workspace import WorkspaceLoader
37
+ WorkspaceLoader.save(root, ws)
38
+
39
+
40
+ def _root_path(root: str) -> Path:
41
+ return Path(root).resolve() if root else Path.cwd()
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # workspace init
46
+ # ---------------------------------------------------------------------------
47
+
48
+ @workspace_app.command("init")
49
+ def workspace_init(
50
+ root: Annotated[
51
+ str,
52
+ typer.Option("--root", help="Directory to create workspace.yaml in."),
53
+ ] = "",
54
+ name: Annotated[
55
+ str,
56
+ typer.Option("--name", help="Workspace name."),
57
+ ] = "default",
58
+ json_output: Annotated[bool, typer.Option("--json")] = False,
59
+ ) -> None:
60
+ """Create a new empty workspace.yaml.
61
+
62
+ Exit codes:
63
+ 0 — success
64
+ 1 — workspace.yaml already exists
65
+ """
66
+ from workspace import WorkspaceLoader
67
+
68
+ r = _root_path(root)
69
+ ws_file = r / "workspace.yaml"
70
+ if ws_file.exists():
71
+ typer.echo(
72
+ f"workspace.yaml already exists at {ws_file}. "
73
+ "Use 'workspace repo add' to add repositories.",
74
+ err=True,
75
+ )
76
+ raise typer.Exit(1)
77
+
78
+ ws = WorkspaceLoader.init(r, name=name)
79
+
80
+ if json_output:
81
+ typer.echo(json.dumps({"name": ws.name, "path": str(ws_file)}))
82
+ else:
83
+ typer.echo(f"Workspace '{ws.name}' initialised at {ws_file}")
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # workspace repo add
88
+ # ---------------------------------------------------------------------------
89
+
90
+ @repo_app.command("add")
91
+ def repo_add(
92
+ name: Annotated[str, typer.Argument(help="Logical name for the repository.")],
93
+ path: Annotated[str, typer.Argument(help="Filesystem path to the repository.")],
94
+ language: Annotated[
95
+ str,
96
+ typer.Option("--language", "-l", help="Primary language (optional)."),
97
+ ] = "",
98
+ root: Annotated[
99
+ str,
100
+ typer.Option("--root", help="Workspace root directory."),
101
+ ] = "",
102
+ auto_detect_links: Annotated[
103
+ bool,
104
+ typer.Option("--detect-links/--no-detect-links", help="Auto-detect cross-repo links."),
105
+ ] = True,
106
+ json_output: Annotated[bool, typer.Option("--json")] = False,
107
+ ) -> None:
108
+ """Add a repository to the workspace.
109
+
110
+ Exit codes:
111
+ 0 — success
112
+ 1 — workspace.yaml not found
113
+ """
114
+ from workspace import RepoRegistry, detect_links
115
+
116
+ r = _root_path(root)
117
+ ws = _load_or_die(r)
118
+ reg = RepoRegistry(ws)
119
+ repo = reg.add(name, Path(path).resolve(), language=language)
120
+
121
+ if auto_detect_links:
122
+ all_repos = reg.get_all()
123
+ detected = detect_links(all_repos)
124
+ for from_repo, targets in detected.items():
125
+ for to_repo in targets:
126
+ reg.add_link(from_repo, to_repo)
127
+
128
+ updated_ws = reg.to_descriptor()
129
+ _save(r, updated_ws)
130
+
131
+ if json_output:
132
+ typer.echo(json.dumps({
133
+ "name": repo.name,
134
+ "path": str(repo.path),
135
+ "branch": repo.branch,
136
+ "sha": repo.sha,
137
+ "dirty": repo.dirty,
138
+ }))
139
+ else:
140
+ status = f"{repo.branch}@{repo.sha}" if repo.branch else "(no git)"
141
+ dirty = " (dirty)" if repo.dirty else ""
142
+ typer.echo(f"Repo added: {repo.name} {status}{dirty}")
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # workspace repo list
147
+ # ---------------------------------------------------------------------------
148
+
149
+ @repo_app.command("list")
150
+ def repo_list(
151
+ root: Annotated[
152
+ str,
153
+ typer.Option("--root", help="Workspace root directory."),
154
+ ] = "",
155
+ json_output: Annotated[bool, typer.Option("--json")] = False,
156
+ ) -> None:
157
+ """List all repos in the workspace.
158
+
159
+ Exit codes:
160
+ 0 — success
161
+ 1 — workspace.yaml not found
162
+ """
163
+ from workspace import RepoRegistry
164
+
165
+ r = _root_path(root)
166
+ ws = _load_or_die(r)
167
+ reg = RepoRegistry(ws)
168
+ repos = reg.get_all()
169
+
170
+ if json_output:
171
+ typer.echo(json.dumps([
172
+ {
173
+ "name": repo.name,
174
+ "path": str(repo.path),
175
+ "branch": repo.branch,
176
+ "sha": repo.sha,
177
+ "dirty": repo.dirty,
178
+ "language": repo.language,
179
+ }
180
+ for repo in repos
181
+ ], indent=2))
182
+ return
183
+
184
+ if not repos:
185
+ typer.echo("No repos in workspace. Use 'context-router workspace repo add' to add one.")
186
+ return
187
+
188
+ typer.echo(f"{'NAME':<20} {'BRANCH':<20} {'SHA':<10} {'DIRTY':<6} PATH")
189
+ typer.echo("-" * 80)
190
+ for repo in repos:
191
+ branch = repo.branch or "—"
192
+ sha = repo.sha[:8] if repo.sha else "—"
193
+ dirty = "yes" if repo.dirty else "no"
194
+ typer.echo(f"{repo.name:<20} {branch:<20} {sha:<10} {dirty:<6} {repo.path}")
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # workspace link add
199
+ # ---------------------------------------------------------------------------
200
+
201
+ @link_app.command("add")
202
+ def link_add(
203
+ from_repo: Annotated[str, typer.Argument(help="Source repository name.")],
204
+ to_repo: Annotated[str, typer.Argument(help="Target repository name (dependency).")],
205
+ root: Annotated[
206
+ str,
207
+ typer.Option("--root", help="Workspace root directory."),
208
+ ] = "",
209
+ json_output: Annotated[bool, typer.Option("--json")] = False,
210
+ ) -> None:
211
+ """Add a manual cross-repo dependency link.
212
+
213
+ Records that FROM_REPO depends on TO_REPO, boosting TO_REPO items
214
+ in cross-repo context packs.
215
+
216
+ Exit codes:
217
+ 0 — success
218
+ 1 — workspace.yaml not found
219
+ """
220
+ from workspace import RepoRegistry
221
+
222
+ r = _root_path(root)
223
+ ws = _load_or_die(r)
224
+ reg = RepoRegistry(ws)
225
+ reg.add_link(from_repo, to_repo)
226
+ _save(r, reg.to_descriptor())
227
+
228
+ if json_output:
229
+ typer.echo(json.dumps({"from": from_repo, "to": to_repo}))
230
+ else:
231
+ typer.echo(f"Link added: {from_repo} → {to_repo}")
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # workspace pack
236
+ # ---------------------------------------------------------------------------
237
+
238
+ @workspace_app.command("pack")
239
+ def workspace_pack(
240
+ mode: Annotated[
241
+ str,
242
+ typer.Option("--mode", "-m", help="review|implement|debug|handover"),
243
+ ],
244
+ query: Annotated[
245
+ str,
246
+ typer.Option("--query", "-q", help="Free-text task description."),
247
+ ] = "",
248
+ root: Annotated[
249
+ str,
250
+ typer.Option("--root", help="Workspace root directory."),
251
+ ] = "",
252
+ json_output: Annotated[bool, typer.Option("--json")] = False,
253
+ ) -> None:
254
+ """Generate a cross-repo context pack for all workspace repos.
255
+
256
+ Exit codes:
257
+ 0 — success
258
+ 1 — workspace.yaml not found or no index
259
+ 2 — invalid mode
260
+ """
261
+ from core.workspace_orchestrator import WorkspaceOrchestrator
262
+
263
+ r = _root_path(root)
264
+
265
+ if mode not in ("review", "implement", "debug", "handover"):
266
+ typer.echo(f"Error: --mode must be one of: review, implement, debug, handover", err=True)
267
+ raise typer.Exit(2)
268
+
269
+ try:
270
+ orch = WorkspaceOrchestrator(workspace_root=r)
271
+ pack = orch.build_pack(mode, query)
272
+ except FileNotFoundError as exc:
273
+ typer.echo(str(exc), err=True)
274
+ raise typer.Exit(1)
275
+
276
+ if json_output:
277
+ typer.echo(pack.model_dump_json(indent=2))
278
+ return
279
+
280
+ if not pack.selected_items:
281
+ typer.echo("No context items found. Run 'context-router index' in each repo first.")
282
+ return
283
+
284
+ typer.echo(f"\n[{pack.mode}] {pack.query or '(no query)'}")
285
+ typer.echo(
286
+ f"~{pack.total_est_tokens:,} tokens "
287
+ f"({pack.reduction_pct:.0f}% reduction)\n"
288
+ )
289
+ typer.echo(f"{'TITLE':<45} {'SOURCE':<18} {'CONF':>5} {'TOK':>6}")
290
+ typer.echo("-" * 78)
291
+ for item in pack.selected_items:
292
+ title = item.title[:44]
293
+ source = item.source_type[:17]
294
+ typer.echo(f"{title:<45} {source:<18} {item.confidence:>5.2f} {item.est_tokens:>6}")