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.
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/PKG-INFO +1 -1
- opencontext_cli-0.3.0/opencontext_cli/__main__.py +5 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/__init__.py +1 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/ci_check_cmd.py +191 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/config_cmd.py +323 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/git_cmd.py +139 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/hints_cmd.py +67 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/kg_cmd.py +193 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/plugin_cmd.py +528 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/setup_cmd.py +582 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/sync_cmd.py +187 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/update_cmd.py +86 -0
- opencontext_cli-0.3.0/opencontext_cli/commands/verify_cmd.py +122 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli/main.py +1288 -715
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/PKG-INFO +1 -1
- opencontext_cli-0.3.0/opencontext_cli.egg-info/SOURCES.txt +23 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/pyproject.toml +1 -1
- opencontext_cli-0.1.0/opencontext_cli.egg-info/SOURCES.txt +0 -11
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/LICENSE +0 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/README.md +0 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli/__init__.py +0 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/dependency_links.txt +0 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/entry_points.txt +0 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/requires.txt +0 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/top_level.txt +0 -0
- {opencontext_cli-0.1.0 → opencontext_cli-0.3.0}/setup.cfg +0 -0
|
@@ -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)
|