opencontext-cli 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/PKG-INFO +1 -1
  2. opencontext_cli-0.3.0/opencontext_cli/__main__.py +5 -0
  3. opencontext_cli-0.3.0/opencontext_cli/commands/__init__.py +1 -0
  4. opencontext_cli-0.3.0/opencontext_cli/commands/ci_check_cmd.py +191 -0
  5. opencontext_cli-0.3.0/opencontext_cli/commands/config_cmd.py +323 -0
  6. opencontext_cli-0.3.0/opencontext_cli/commands/git_cmd.py +139 -0
  7. opencontext_cli-0.3.0/opencontext_cli/commands/hints_cmd.py +67 -0
  8. opencontext_cli-0.3.0/opencontext_cli/commands/kg_cmd.py +193 -0
  9. opencontext_cli-0.3.0/opencontext_cli/commands/plugin_cmd.py +528 -0
  10. opencontext_cli-0.3.0/opencontext_cli/commands/setup_cmd.py +582 -0
  11. opencontext_cli-0.3.0/opencontext_cli/commands/sync_cmd.py +187 -0
  12. opencontext_cli-0.3.0/opencontext_cli/commands/update_cmd.py +86 -0
  13. opencontext_cli-0.3.0/opencontext_cli/commands/verify_cmd.py +122 -0
  14. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli/main.py +1288 -715
  15. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/PKG-INFO +1 -1
  16. opencontext_cli-0.3.0/opencontext_cli.egg-info/SOURCES.txt +23 -0
  17. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/pyproject.toml +1 -1
  18. opencontext_cli-0.1.0/opencontext_cli.egg-info/SOURCES.txt +0 -11
  19. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/LICENSE +0 -0
  20. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/README.md +0 -0
  21. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli/__init__.py +0 -0
  22. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/dependency_links.txt +0 -0
  23. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/entry_points.txt +0 -0
  24. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/requires.txt +0 -0
  25. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/top_level.txt +0 -0
  26. {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencontext-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: CLI adapter for OpenContext Runtime
5
5
  Author: OpenContext Runtime maintainers
6
6
  License-Expression: MIT
@@ -0,0 +1,5 @@
1
+ """CLI entry point for `python -m opencontext_cli`."""
2
+
3
+ from opencontext_cli.main import main
4
+
5
+ main()
@@ -0,0 +1 @@
1
+ """CLI command modules for OpenContext."""
@@ -0,0 +1,191 @@
1
+ """CI check CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from opencontext_core.dx.console_styles import console
10
+ from opencontext_core.quality.ci_checks import CheckRunner
11
+
12
+ CONTEXTBENCH_WORKFLOW = """\
13
+ # OpenContext ContextBench CI
14
+ # Auto-generated by `opencontext ci-check init`
15
+ name: OpenContext ContextBench
16
+
17
+ on:
18
+ push:
19
+ branches: [main, master]
20
+ pull_request:
21
+ branches: [main, master]
22
+
23
+ jobs:
24
+ contextbench:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - name: Install OpenContext
29
+ run: pip install opencontext-cli
30
+ - name: Initialize checks
31
+ run: opencontext ci-check init
32
+ - name: Run ContextBench checks
33
+ run: opencontext ci-check run --json > contextbench-report.json
34
+ - name: Upload report
35
+ uses: actions/upload-artifact@v4
36
+ with:
37
+ name: contextbench-report
38
+ path: contextbench-report.json
39
+ - name: Fail on errors
40
+ run: |
41
+ python3 -c "
42
+ import json
43
+ with open('contextbench-report.json') as f:
44
+ report = json.load(f)
45
+ summary = report.get('summary', {})
46
+ if summary.get('failed', 0) > 0:
47
+ print('❌ ContextBench checks failed')
48
+ exit(1)
49
+ print('✅ All ContextBench checks passed')
50
+ "
51
+ """
52
+
53
+
54
+ def add_ci_check_parser(subparsers: Any) -> None:
55
+ """Add ci-check command parsers."""
56
+ check_parser = subparsers.add_parser("ci-check", help="CI check management.")
57
+ check_sub = check_parser.add_subparsers(dest="ci_check_command", required=True)
58
+ check_init = check_sub.add_parser(
59
+ "init", help="Initialize checks directory and ContextBench workflow."
60
+ )
61
+ check_init.add_argument(
62
+ "--no-workflow", action="store_true", help="Skip GitHub Actions workflow generation."
63
+ )
64
+ check_sub.add_parser("list", help="List discovered checks.")
65
+ check_run = check_sub.add_parser("run", help="Run all checks.")
66
+ check_run.add_argument("--file", help="Run on specific file only.")
67
+ check_run.add_argument("--json", action="store_true")
68
+ check_create = check_sub.add_parser("create", help="Create a new check template.")
69
+ check_create.add_argument("name", help="Check name.")
70
+ check_create.add_argument("--description", default="", help="Check description.")
71
+ check_gh = check_sub.add_parser(
72
+ "github-actions", help="Generate ContextBench GitHub Actions workflow."
73
+ )
74
+ check_gh.add_argument("--force", action="store_true", help="Overwrite existing workflow file.")
75
+
76
+
77
+ def handle_ci_check(args: Any) -> None:
78
+ """Handle ci-check commands."""
79
+ command = args.ci_check_command
80
+ name = getattr(args, "name", None)
81
+ file = getattr(args, "file", None)
82
+ json_output = getattr(args, "json", False)
83
+
84
+ runner = CheckRunner()
85
+
86
+ if command == "init":
87
+ path = runner.init_checks_directory()
88
+ console.success(f"Initialized checks directory: {path}")
89
+ skip_workflow = getattr(args, "no_workflow", False)
90
+ if not skip_workflow:
91
+ workflow_path = _generate_contextbench_workflow()
92
+ console.success(f"Generated ContextBench workflow: {workflow_path}")
93
+ elif command == "github-actions":
94
+ force = getattr(args, "force", False)
95
+ workflow_path = Path(".github/workflows/opencontext-contextbench.yml")
96
+ if workflow_path.exists() and not force:
97
+ console.warning(f"Workflow already exists: {workflow_path}. Use --force to overwrite.")
98
+ return
99
+ _write_contextbench_workflow(workflow_path)
100
+ console.success(f"Generated ContextBench workflow: {workflow_path}")
101
+ elif command == "list":
102
+ checks = runner.discover_checks()
103
+ if json_output:
104
+ data = [
105
+ {"name": c.name, "description": c.description, "severity": c.severity.value}
106
+ for c in checks
107
+ ]
108
+ print(json.dumps(data, indent=2))
109
+ else:
110
+ console.header("CI Checks")
111
+ if not checks:
112
+ console.dim("No checks found. Run 'opencontext ci-check init'")
113
+ else:
114
+ console.table(
115
+ "Discovered Checks",
116
+ ["Name", "Severity", "Description"],
117
+ [[c.name, c.severity.value, c.description] for c in checks],
118
+ )
119
+ elif command == "run":
120
+ files = [file] if file else None
121
+ with console.progress("Running checks...") as progress:
122
+ task = progress.add_task("Running checks...", total=None)
123
+ results = runner.run_all_checks(files)
124
+ progress.update(task, completed=True)
125
+ report = runner.generate_report(results)
126
+ if json_output:
127
+ print(json.dumps(report, indent=2))
128
+ else:
129
+ _display_check_report(report)
130
+ elif command == "create" and name:
131
+ template = runner.create_check_template(name, "Custom check")
132
+ console.print(template)
133
+ else:
134
+ console.error(f"Unknown ci-check command: {command}")
135
+
136
+
137
+ def _display_check_report(report: dict[str, Any]) -> None:
138
+ """Display a formatted check report."""
139
+ summary = report.get("summary", {})
140
+ total = summary.get("total_checks", 0)
141
+ passed = summary.get("passed", 0)
142
+ failed = summary.get("failed", 0)
143
+ warnings = summary.get("warnings", 0)
144
+ errors = summary.get("errors", 0)
145
+ success = summary.get("success", False)
146
+
147
+ console.header("Check Report")
148
+
149
+ if success:
150
+ console.success(f"All {total} checks passed")
151
+ else:
152
+ console.error(f"{failed}/{total} checks failed")
153
+
154
+ console.table(
155
+ "Summary",
156
+ ["Metric", "Count"],
157
+ [
158
+ ["Total", str(total)],
159
+ ["Passed", str(passed)],
160
+ ["Failed", str(failed)],
161
+ ["Warnings", str(warnings)],
162
+ ["Errors", str(errors)],
163
+ ],
164
+ )
165
+
166
+ # Show failed results
167
+ failed_results = [r for r in report.get("results", []) if r["status"] != "passed"]
168
+ if failed_results:
169
+ console.section("Failed Checks")
170
+ for r in failed_results:
171
+ severity_color = "#FF6F91" if r["severity"] in ("error", "critical") else "#FFC75F"
172
+ msg = f" [bold {severity_color}]{r['severity'].upper()}[/]"
173
+ msg += f" {r['check']}: {r['message']}"
174
+ console.print(msg)
175
+ if r.get("file"):
176
+ console.print(f" [dim]File: {r['file']}:{r.get('line', 'N/A')}[/]")
177
+ if r.get("suggestion"):
178
+ console.print(f" [dim]Suggestion: {r['suggestion']}[/]")
179
+
180
+
181
+ def _generate_contextbench_workflow() -> Path:
182
+ """Generate the ContextBench GitHub Actions workflow as part of init."""
183
+ workflow_path = Path(".github/workflows/opencontext-contextbench.yml")
184
+ _write_contextbench_workflow(workflow_path)
185
+ return workflow_path
186
+
187
+
188
+ def _write_contextbench_workflow(workflow_path: Path) -> None:
189
+ """Write the ContextBench workflow file."""
190
+ workflow_path.parent.mkdir(parents=True, exist_ok=True)
191
+ workflow_path.write_text(CONTEXTBENCH_WORKFLOW)
@@ -0,0 +1,323 @@
1
+ """Configuration management CLI commands.
2
+
3
+ Provides wizard, show, reset, reconfigure, backup,
4
+ restore, and cleanup subcommands for managing
5
+ OpenContext user preferences.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from typing import Any
13
+
14
+ from opencontext_core.state import ConfigBackupManager
15
+ from opencontext_core.user_prefs import UserConfigStore
16
+ from opencontext_core.wizard import (
17
+ reconfigure,
18
+ reset_config,
19
+ run_wizard,
20
+ show_config,
21
+ )
22
+
23
+
24
+ def add_config_parser(subparsers: Any) -> None:
25
+ """Add config command parsers."""
26
+
27
+ config_parser = subparsers.add_parser("config", help="Manage OpenContext configuration.")
28
+ config_sub = config_parser.add_subparsers(dest="config_command", required=True)
29
+
30
+ # Wizard
31
+ wizard_parser = config_sub.add_parser("wizard", help="Run configuration wizard.")
32
+ wizard_parser.add_argument(
33
+ "--non-interactive", action="store_true", help="Use defaults without prompts."
34
+ )
35
+
36
+ # Show
37
+ config_sub.add_parser("show", help="Display current configuration.")
38
+
39
+ # Reset
40
+ config_sub.add_parser("reset", help="Reset to factory defaults.")
41
+
42
+ # Reconfigure section
43
+ reconf_parser = config_sub.add_parser("reconfigure", help="Reconfigure a specific section.")
44
+ reconf_parser.add_argument(
45
+ "section",
46
+ choices=["security", "features", "tokens", "agents", "plugins"],
47
+ help="Section to reconfigure.",
48
+ )
49
+
50
+ # Set individual values
51
+ set_parser = config_sub.add_parser("set", help="Set a configuration value.")
52
+ set_parser.add_argument("key", help="Configuration key (dot notation).")
53
+ set_parser.add_argument("value", help="Value to set.")
54
+
55
+ # Get individual values
56
+ get_parser = config_sub.add_parser("get", help="Get a configuration value.")
57
+ get_parser.add_argument("key", help="Configuration key (dot notation).")
58
+
59
+ # Backup
60
+ config_sub.add_parser("backup", help="Create a manual backup of configuration.")
61
+
62
+ # List backups
63
+ config_sub.add_parser("backups", help="List saved configuration backups.")
64
+
65
+ # Restore
66
+ restore_parser = config_sub.add_parser("restore", help="Restore configuration from a backup.")
67
+ restore_parser.add_argument("id", help="Backup ID to restore.")
68
+
69
+ # Cleanup old backups
70
+ cleanup_parser = config_sub.add_parser("cleanup", help="Remove old backups.")
71
+ cleanup_parser.add_argument(
72
+ "--keep-days", type=int, default=30, help="Keep backups newer than this many days."
73
+ )
74
+
75
+
76
+ def handle_config(args: Any) -> None:
77
+ """Handle config commands."""
78
+
79
+ command = args.config_command
80
+
81
+ if command == "wizard":
82
+ use_tui = not getattr(args, "non_interactive", False)
83
+ if use_tui:
84
+ from opencontext_core.wizard import run_wizard_menu
85
+
86
+ run_wizard_menu()
87
+ else:
88
+ run_wizard(non_interactive=True)
89
+ elif command == "show":
90
+ show_config()
91
+ elif command == "reset":
92
+ reset_config()
93
+ elif command == "reconfigure":
94
+ reconfigure(args.section)
95
+ elif command == "set":
96
+ _config_set(args.key, args.value)
97
+ elif command == "get":
98
+ _config_get(args.key)
99
+ elif command == "backup":
100
+ _config_backup()
101
+ elif command == "backups":
102
+ _config_backups()
103
+ elif command == "restore":
104
+ _config_restore(args.id)
105
+ elif command == "cleanup":
106
+ _config_cleanup(args.keep_days)
107
+
108
+
109
+ # ── Dot-notation config paths ──────────────────────────────────────────────
110
+
111
+ # Schema of configurable paths: "path" -> (type, description)
112
+ CONFIG_PATHS: dict[str, tuple[type, str]] = {
113
+ # Flat keys
114
+ "security_mode": (str, "Security mode: private_project, enterprise, or air-gapped"),
115
+ "default_token_budget": (int, "Default token budget per operation"),
116
+ "max_input_tokens": (int, "Maximum input tokens"),
117
+ "reserve_output_tokens": (int, "Reserved output tokens"),
118
+ "check_updates": (bool, "Check for updates automatically"),
119
+ "auto_optimize": (bool, "Auto-optimize token budgets based on usage"),
120
+ "first_run": (bool, "Whether this is the first run"),
121
+ "default_provider": (str, "Default LLM provider"),
122
+ "default_model": (str, "Default LLM model"),
123
+ # Nested: features.*
124
+ "features.knowledge_graph": (bool, "Knowledge Graph (code indexing & search)"),
125
+ "features.call_graph": (bool, "Call Graph (function call analysis)"),
126
+ "features.learning_system": (bool, "Learning System (auto-optimize)"),
127
+ "features.governance": (bool, "Governance (audit trails & policies)"),
128
+ "features.mcp_server": (bool, "MCP Server (agent integration)"),
129
+ "features.git_integration": (bool, "Git Integration"),
130
+ "features.embeddings": (bool, "Embeddings (semantic search)"),
131
+ "features.semantic_search": (bool, "Semantic Search"),
132
+ # Nested: sdd.*
133
+ "sdd.tdd_mode": (str, "TDD mode: ask, strict, or off"),
134
+ "sdd.sdd_model_profile": (str, "SDD model profile: default, cheap, hybrid, premium"),
135
+ "sdd.orchestrator_profile": (
136
+ str,
137
+ "Orchestrator profile: solo-compact, multi-phase, subagent-native",
138
+ ),
139
+ # Nested: agents.*
140
+ "agents.default_client": (str, "Default agent client"),
141
+ "agents.active_clients": (list, "Active agent clients (comma-separated)"),
142
+ }
143
+
144
+
145
+ def _resolve_config_path(prefs: Any, dotted: str) -> tuple[Any, str] | None:
146
+ """Resolve a dotted path to (parent_object, attr_name) or None if invalid.
147
+
148
+ Example: "features.knowledge_graph" -> (prefs.features, "knowledge_graph")
149
+ """
150
+ parts = dotted.split(".")
151
+ obj = prefs
152
+ for _i, part in enumerate(parts[:-1]):
153
+ if hasattr(obj, part):
154
+ obj = getattr(obj, part)
155
+ else:
156
+ return None
157
+ return (obj, parts[-1])
158
+
159
+
160
+ def _get_all_config_paths() -> list[str]:
161
+ """Return all available config paths sorted."""
162
+ return sorted(CONFIG_PATHS.keys())
163
+
164
+
165
+ def _coerce_value(value: str, target_type: type) -> object:
166
+ """Coerce a string value to the target type."""
167
+ if target_type is bool:
168
+ return value.lower() in ("true", "1", "yes", "on")
169
+ elif target_type is int:
170
+ return int(value)
171
+ elif target_type is list:
172
+ import json
173
+
174
+ try:
175
+ parsed = json.loads(value)
176
+ if isinstance(parsed, list):
177
+ return parsed
178
+ except (json.JSONDecodeError, TypeError):
179
+ pass
180
+ # Fallback: comma-separated
181
+ return [item.strip() for item in value.split(",") if item.strip()]
182
+ else:
183
+ return value
184
+
185
+
186
+ def _config_set(key: str, value: str) -> None:
187
+ """Set a config value using dot notation."""
188
+
189
+ store = UserConfigStore()
190
+ prefs = store.load()
191
+
192
+ if key in CONFIG_PATHS:
193
+ _target_type, _description = CONFIG_PATHS[key]
194
+ resolved = _resolve_config_path(prefs, key)
195
+ if resolved is None:
196
+ print(f"Error: Cannot resolve path '{key}'")
197
+ return
198
+ parent, attr = resolved
199
+ try:
200
+ parsed = _coerce_value(value, _target_type)
201
+ setattr(parent, attr, parsed)
202
+ store.save(prefs)
203
+ print(f"Set {key} = {parsed}")
204
+ except (ValueError, TypeError) as exc:
205
+ print(f"Error: Cannot set '{key}' to '{value}': {exc}")
206
+ print(f"Expected type: {_target_type.__name__}")
207
+ else:
208
+ print(f"Unknown key: {key}")
209
+ print(f"Available paths ({len(CONFIG_PATHS)}):")
210
+ for path, (typ, desc) in sorted(CONFIG_PATHS.items()):
211
+ print(f" {path} ({typ.__name__}) {desc}")
212
+
213
+
214
+ def _config_get(key: str) -> None:
215
+ """Get a config value by dot-notation key."""
216
+
217
+ store = UserConfigStore()
218
+ prefs = store.load()
219
+
220
+ if key in CONFIG_PATHS:
221
+ _target_type, _description = CONFIG_PATHS[key]
222
+ resolved = _resolve_config_path(prefs, key)
223
+ if resolved is None:
224
+ print(f"Error: Cannot resolve path '{key}'")
225
+ return
226
+ parent, attr = resolved
227
+ value = getattr(parent, attr, "<not set>")
228
+ print(f"{key} = {value}")
229
+ else:
230
+ print(f"Unknown key: {key}")
231
+ print(f"Available paths ({len(CONFIG_PATHS)}):")
232
+ for path, (typ, desc) in sorted(CONFIG_PATHS.items()):
233
+ print(f" {path} ({typ.__name__}) {desc}")
234
+
235
+
236
+ def _config_backup() -> None:
237
+ """Create a manual backup."""
238
+
239
+ backup_id = ConfigBackupManager.create_backup(description="manual")
240
+ print(f" ✓ Backup created: {backup_id}")
241
+ print(f" Location: {ConfigBackupManager.BACKUP_DIR / backup_id}")
242
+
243
+
244
+ def _config_backups() -> None:
245
+ """List backups."""
246
+
247
+ backups = ConfigBackupManager.list_backups()
248
+ if not backups:
249
+ print(" No backups found.")
250
+ print(f" Backup directory: {ConfigBackupManager.BACKUP_DIR}")
251
+ return
252
+
253
+ print()
254
+ print(f" {'Backup ID':<30} {'Timestamp':<25} {'Description':<20} {'Files'}")
255
+ print(f" {'─' * 30} {'─' * 25} {'─' * 20} {'─' * 20}")
256
+ for b in backups:
257
+ files_str = ", ".join(b.files) if b.files else "—"
258
+ print(f" {b.id:<30} {b.timestamp:<25} {b.description:<20} {files_str}")
259
+ print(f"\n {len(backups)} backup(s) available")
260
+ print("\n Restore: opencontext config restore <id>")
261
+
262
+
263
+ def _config_restore(backup_id: str) -> None:
264
+ """Restore from a backup."""
265
+
266
+ if ConfigBackupManager.restore_backup(backup_id):
267
+ print(f" ✓ Restored from backup: {backup_id}")
268
+ else:
269
+ print(f" ✗ Backup not found: {backup_id}")
270
+ print(" List available: opencontext config backups")
271
+ sys.exit(1)
272
+
273
+
274
+ def _config_cleanup(keep_days: int) -> None:
275
+ """Clean up old backups beyond keep_days."""
276
+
277
+ from datetime import datetime, timedelta
278
+
279
+ backups = ConfigBackupManager.list_backups()
280
+ cutoff = datetime.now() - timedelta(days=keep_days)
281
+ removed = 0
282
+
283
+ for b in backups:
284
+ try:
285
+ ts = datetime.strptime(b.timestamp, "%Y%m%dT%H%M%S")
286
+ if ts < cutoff:
287
+ backup_dir = ConfigBackupManager.BACKUP_DIR / b.id
288
+ if backup_dir.exists():
289
+ import shutil
290
+
291
+ shutil.rmtree(backup_dir)
292
+ removed += 1
293
+ except (ValueError, OSError):
294
+ continue
295
+
296
+ # Rebuild index
297
+ [b for b in backups if b.id not in [r.id for r in ConfigBackupManager.list_backups()]]
298
+ # Actually let's rebuild from disk
299
+ index = []
300
+ for entry_dir in sorted(ConfigBackupManager.BACKUP_DIR.iterdir()):
301
+ if entry_dir.is_dir() and entry_dir.name.startswith("backup-"):
302
+ try:
303
+ ts = entry_dir.name.replace("backup-", "")
304
+ desc = "auto-pre-change" # rough default
305
+ files = []
306
+ for f in entry_dir.iterdir():
307
+ if f.is_file():
308
+ files.append(f.name)
309
+ index.append(
310
+ {
311
+ "id": entry_dir.name,
312
+ "timestamp": ts,
313
+ "description": desc,
314
+ "files": files,
315
+ }
316
+ )
317
+ except OSError:
318
+ continue
319
+
320
+ ConfigBackupManager.INDEX_FILE.write_text(json.dumps(index, indent=2), encoding="utf-8")
321
+
322
+ print(f" ✓ Removed {removed} backup(s) older than {keep_days} days")
323
+ print(f" {len(index)} backup(s) remaining")
@@ -0,0 +1,139 @@
1
+ """Git context CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from opencontext_core.dx.console_styles import console
9
+ from opencontext_core.indexing.git_context import GitContextProvider
10
+
11
+
12
+ def add_git_parser(subparsers: Any) -> None:
13
+ """Add git command parsers."""
14
+ git_parser = subparsers.add_parser("git", help="Git context and history.")
15
+ git_sub = git_parser.add_subparsers(dest="git_command", required=True)
16
+ git_sub.add_parser("status", help="Show git repository stats.")
17
+ git_history = git_sub.add_parser("history", help="Show git history for a file.")
18
+ git_history.add_argument("file", help="File path.")
19
+ git_history.add_argument("--json", action="store_true")
20
+ git_recent = git_sub.add_parser("recent", help="Show recent changes.")
21
+ git_recent.add_argument("--days", type=int, default=7)
22
+ git_recent.add_argument("--max-commits", type=int, default=20)
23
+ git_recent.add_argument("--json", action="store_true")
24
+ git_blame = git_sub.add_parser("blame", help="Show blame for file lines.")
25
+ git_blame.add_argument("file", help="File path.")
26
+ git_blame.add_argument("--start", type=int, default=1)
27
+ git_blame.add_argument("--end", type=int, default=10)
28
+ git_blame.add_argument("--json", action="store_true")
29
+
30
+
31
+ def handle_git(args: Any) -> None:
32
+ """Handle git commands."""
33
+ command = args.git_command
34
+ file = getattr(args, "file", None)
35
+ start = getattr(args, "start", 1)
36
+ end = getattr(args, "end", 10)
37
+ days = getattr(args, "days", 7)
38
+ max_commits = getattr(args, "max_commits", 20)
39
+ json_output = getattr(args, "json", False)
40
+
41
+ provider = GitContextProvider()
42
+ if not provider.available:
43
+ console.error("Not a git repository")
44
+ return
45
+
46
+ if command == "status":
47
+ stats = provider.get_repo_stats()
48
+ if json_output:
49
+ print(json.dumps(stats, indent=2))
50
+ else:
51
+ console.header("Git Repository Stats")
52
+ console.print(_format_git_status(stats))
53
+ elif command == "history" and file:
54
+ info = provider.get_file_info(file)
55
+ if info:
56
+ if json_output:
57
+ data = {
58
+ "path": info.path,
59
+ "last_modified": info.last_modified.isoformat() if info.last_modified else None,
60
+ "last_author": info.last_author,
61
+ "commit_count": info.commit_count,
62
+ "lines_added": info.lines_added,
63
+ "lines_removed": info.lines_removed,
64
+ "top_authors": info.top_authors,
65
+ }
66
+ print(json.dumps(data, indent=2))
67
+ else:
68
+ console.header(f"Git History: {file}")
69
+ console.print(_format_git_history(info))
70
+ else:
71
+ console.error(f"Could not get history for {file}")
72
+ elif command == "recent":
73
+ diffs = provider.get_recent_changes(days=days, max_commits=max_commits)
74
+ if json_output:
75
+ data = [
76
+ {
77
+ "commit_hash": d.commit_hash,
78
+ "author": d.author,
79
+ "date": d.date.isoformat(),
80
+ "message": d.message,
81
+ "files_changed": d.files_changed,
82
+ }
83
+ for d in diffs
84
+ ]
85
+ print(json.dumps(data, indent=2))
86
+ else:
87
+ console.header(f"Recent Changes (last {days} days)")
88
+ console.print(_format_git_recent(diffs))
89
+ elif command == "blame" and file:
90
+ lines = provider.get_blame_for_symbol(file, start, end)
91
+ if json_output:
92
+ print(json.dumps(lines, indent=2))
93
+ else:
94
+ console.header(f"Blame: {file} (lines {start}-{end})")
95
+ console.print(_format_git_blame(lines))
96
+ else:
97
+ console.error(f"Unknown git command: {command}")
98
+
99
+
100
+ def _format_git_status(stats: dict[str, Any]) -> str:
101
+ if not stats.get("available"):
102
+ return "Not a git repository."
103
+ lines = [
104
+ f"Commits: {stats.get('total_commits', 'N/A')}",
105
+ f"Contributors: {stats.get('contributors', 'N/A')}",
106
+ f"Branches: {stats.get('branches', 'N/A')}",
107
+ ]
108
+ return "\n".join(lines)
109
+
110
+
111
+ def _format_git_history(info: Any) -> str:
112
+ lines = [
113
+ f"File: {info.path}",
114
+ f"Commits: {info.commit_count}",
115
+ f"Last author: {info.last_author or 'N/A'}",
116
+ f"Last modified: {info.last_modified.isoformat() if info.last_modified else 'N/A'}",
117
+ f"Lines added/removed: +{info.lines_added}/-{info.lines_removed}",
118
+ f"Top authors: {', '.join(info.top_authors) if info.top_authors else 'N/A'}",
119
+ ]
120
+ return "\n".join(lines)
121
+
122
+
123
+ def _format_git_recent(diffs: list[Any]) -> str:
124
+ lines = []
125
+ for d in diffs:
126
+ lines.append(f"{d.commit_hash[:8]} {d.author} {d.date.isoformat()}")
127
+ lines.append(f" {d.message}")
128
+ lines.append(f" Files: {', '.join(d.files_changed[:5])}")
129
+ lines.append("")
130
+ return "\n".join(lines)
131
+
132
+
133
+ def _format_git_blame(lines: list[dict[str, Any]]) -> str:
134
+ out = []
135
+ for line in lines:
136
+ author = line.get("author", "?")
137
+ code = line.get("code", "")
138
+ out.append(f"{author:20} {code}")
139
+ return "\n".join(out)