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/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/benchmark.py +153 -0
- cli/commands/decisions.py +169 -0
- cli/commands/explain.py +46 -0
- cli/commands/graph.py +338 -0
- cli/commands/index.py +148 -0
- cli/commands/init.py +79 -0
- cli/commands/mcp.py +30 -0
- cli/commands/memory.py +145 -0
- cli/commands/pack.py +110 -0
- cli/commands/watch.py +126 -0
- cli/commands/workspace.py +294 -0
- cli/main.py +47 -0
- context_router_cli-0.2.0.dist-info/METADATA +632 -0
- context_router_cli-0.2.0.dist-info/RECORD +18 -0
- context_router_cli-0.2.0.dist-info/WHEEL +4 -0
- context_router_cli-0.2.0.dist-info/entry_points.txt +2 -0
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}")
|