opencontext-cli 0.2.1b0__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.2.1b0 → 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.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/ci_check_cmd.py +77 -1
  4. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/config_cmd.py +116 -35
  5. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/plugin_cmd.py +163 -1
  6. opencontext_cli-0.3.0/opencontext_cli/commands/setup_cmd.py +582 -0
  7. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/update_cmd.py +2 -2
  8. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/verify_cmd.py +2 -2
  9. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/main.py +852 -735
  10. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/PKG-INFO +1 -1
  11. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/SOURCES.txt +1 -0
  12. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/pyproject.toml +1 -1
  13. opencontext_cli-0.2.1b0/opencontext_cli/commands/setup_cmd.py +0 -346
  14. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/LICENSE +0 -0
  15. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/README.md +0 -0
  16. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/__init__.py +0 -0
  17. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/__init__.py +0 -0
  18. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/git_cmd.py +0 -0
  19. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/hints_cmd.py +0 -0
  20. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/kg_cmd.py +0 -0
  21. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli/commands/sync_cmd.py +0 -0
  22. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/dependency_links.txt +0 -0
  23. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/entry_points.txt +0 -0
  24. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/requires.txt +0 -0
  25. {opencontext_cli-0.2.1b0 → opencontext_cli-0.3.0}/opencontext_cli.egg-info/top_level.txt +0 -0
  26. {opencontext_cli-0.2.1b0 → 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.2.1b0
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()
@@ -3,17 +3,64 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from pathlib import Path
6
7
  from typing import Any
7
8
 
8
9
  from opencontext_core.dx.console_styles import console
9
10
  from opencontext_core.quality.ci_checks import CheckRunner
10
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
+
11
53
 
12
54
  def add_ci_check_parser(subparsers: Any) -> None:
13
55
  """Add ci-check command parsers."""
14
56
  check_parser = subparsers.add_parser("ci-check", help="CI check management.")
15
57
  check_sub = check_parser.add_subparsers(dest="ci_check_command", required=True)
16
- check_sub.add_parser("init", help="Initialize checks directory.")
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
+ )
17
64
  check_sub.add_parser("list", help="List discovered checks.")
18
65
  check_run = check_sub.add_parser("run", help="Run all checks.")
19
66
  check_run.add_argument("--file", help="Run on specific file only.")
@@ -21,6 +68,10 @@ def add_ci_check_parser(subparsers: Any) -> None:
21
68
  check_create = check_sub.add_parser("create", help="Create a new check template.")
22
69
  check_create.add_argument("name", help="Check name.")
23
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.")
24
75
 
25
76
 
26
77
  def handle_ci_check(args: Any) -> None:
@@ -35,6 +86,18 @@ def handle_ci_check(args: Any) -> None:
35
86
  if command == "init":
36
87
  path = runner.init_checks_directory()
37
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}")
38
101
  elif command == "list":
39
102
  checks = runner.discover_checks()
40
103
  if json_output:
@@ -113,3 +176,16 @@ def _display_check_report(report: dict[str, Any]) -> None:
113
176
  console.print(f" [dim]File: {r['file']}:{r.get('line', 'N/A')}[/]")
114
177
  if r.get("suggestion"):
115
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)
@@ -79,7 +79,13 @@ def handle_config(args: Any) -> None:
79
79
  command = args.config_command
80
80
 
81
81
  if command == "wizard":
82
- run_wizard(non_interactive=getattr(args, "non_interactive", False))
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)
83
89
  elif command == "show":
84
90
  show_config()
85
91
  elif command == "reset":
@@ -100,56 +106,131 @@ def handle_config(args: Any) -> None:
100
106
  _config_cleanup(args.keep_days)
101
107
 
102
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
+
103
186
  def _config_set(key: str, value: str) -> None:
104
- """Set a config value by key."""
187
+ """Set a config value using dot notation."""
105
188
 
106
189
  store = UserConfigStore()
107
190
  prefs = store.load()
108
191
 
109
- # Simple key-value mapping
110
- key_map: dict[str, tuple[str, type]] = {
111
- "security_mode": ("security_mode", str),
112
- "token_budget": ("default_token_budget", int),
113
- "max_input_tokens": ("max_input_tokens", int),
114
- "check_updates": ("check_updates", bool),
115
- "auto_optimize": ("learning_auto_optimize", bool),
116
- }
117
-
118
- if key in key_map:
119
- attr_name, attr_type = key_map[key]
120
- if attr_type is bool:
121
- parsed = value.lower() in ("true", "1", "yes", "on")
122
- elif attr_type is int:
123
- parsed = int(value)
124
- else:
125
- parsed = value
126
- setattr(prefs, attr_name, parsed)
127
- store.save(prefs)
128
- print(f"Set {key} = {parsed}")
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__}")
129
207
  else:
130
208
  print(f"Unknown key: {key}")
131
- print(f"Available: {', '.join(key_map.keys())}")
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}")
132
212
 
133
213
 
134
214
  def _config_get(key: str) -> None:
135
- """Get a config value by key."""
215
+ """Get a config value by dot-notation key."""
136
216
 
137
217
  store = UserConfigStore()
138
218
  prefs = store.load()
139
219
 
140
- key_map = {
141
- "security_mode": prefs.security_mode,
142
- "token_budget": prefs.default_token_budget,
143
- "max_input_tokens": prefs.max_input_tokens,
144
- "check_updates": prefs.check_updates,
145
- "auto_optimize": prefs.learning_auto_optimize,
146
- "first_run": prefs.first_run,
147
- }
148
-
149
- if key in key_map:
150
- print(f"{key} = {key_map[key]}")
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}")
151
229
  else:
152
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}")
153
234
 
154
235
 
155
236
  def _config_backup() -> None:
@@ -3,6 +3,7 @@
3
3
  Usage:
4
4
  opencontext plugin list List installed plugins
5
5
  opencontext plugin search [query] Search remote registry
6
+ opencontext plugin init <name> Scaffold a new plugin
6
7
  opencontext plugin install <name> Install from registry
7
8
  opencontext plugin install <name> --github owner/repo
8
9
  opencontext plugin install <name> --url <url>
@@ -16,6 +17,7 @@ Usage:
16
17
  from __future__ import annotations
17
18
 
18
19
  import json
20
+ from pathlib import Path
19
21
  from typing import Any
20
22
 
21
23
  from opencontext_core.plugin_system import (
@@ -44,6 +46,18 @@ def add_plugin_parser(subparsers: Any) -> None:
44
46
  "--refresh", action="store_true", help="Force refresh registry cache."
45
47
  )
46
48
 
49
+ # Init
50
+ init_parser = plugin_sub.add_parser("init", help="Scaffold a new plugin.")
51
+ init_parser.add_argument("name", help="Plugin name (alphanumeric + hyphens).")
52
+ init_parser.add_argument("--description", default="", help="Short plugin description.")
53
+ init_parser.add_argument("--author", default="", help="Plugin author name.")
54
+ init_parser.add_argument(
55
+ "--template",
56
+ choices=["basic", "advanced"],
57
+ default="basic",
58
+ help="Scaffold template to use (default: basic).",
59
+ )
60
+
47
61
  # Install
48
62
  install_parser = plugin_sub.add_parser("install", help="Install a plugin.")
49
63
  install_parser.add_argument("name", help="Plugin name.")
@@ -65,6 +79,7 @@ def add_plugin_parser(subparsers: Any) -> None:
65
79
  # Info
66
80
  info_parser = plugin_sub.add_parser("info", help="Show plugin details.")
67
81
  info_parser.add_argument("name", help="Plugin name.")
82
+ info_parser.add_argument("--json", action="store_true", help="Output as JSON.")
68
83
 
69
84
  # Enable/Disable
70
85
  enable_parser = plugin_sub.add_parser("enable", help="Enable a plugin.")
@@ -83,6 +98,8 @@ def handle_plugin(args: Any) -> None:
83
98
  _plugin_list(args)
84
99
  elif command == "search":
85
100
  _plugin_search(args)
101
+ elif command == "init":
102
+ _plugin_init(args)
86
103
  elif command == "install":
87
104
  _plugin_install(args)
88
105
  elif command == "remove":
@@ -180,6 +197,107 @@ def _plugin_search(args: Any) -> None:
180
197
  print(" Details: opencontext plugin info <name>")
181
198
 
182
199
 
200
+ def _plugin_init(args: Any) -> None:
201
+ """Scaffold a new plugin directory."""
202
+
203
+ name = args.name.strip()
204
+ if not name.replace("-", "").replace("_", "").isalnum():
205
+ print(f"\n ✗ Invalid plugin name: '{name}'. Use alphanumeric, hyphens, or underscores.\n")
206
+ return
207
+
208
+ plugin_dir = Path.cwd() / name
209
+ if plugin_dir.exists():
210
+ print(f"\n ✗ Directory '{name}' already exists.\n")
211
+ return
212
+
213
+ description = args.description or f"Plugin '{name}'"
214
+ author = args.author or ""
215
+ class_name = "".join(part.capitalize() for part in name.replace("-", "_").split("_"))
216
+ if class_name.endswith("Plugin"):
217
+ base_name = class_name
218
+ else:
219
+ base_name = f"{class_name}Plugin"
220
+
221
+ plugin_dir.mkdir(parents=True, exist_ok=True)
222
+
223
+ # --- plugin.yaml ---
224
+ yaml_content = (
225
+ f"name: {name}\n"
226
+ f"version: 0.1.0\n"
227
+ f"description: {description}\n"
228
+ f"author: {author}\n"
229
+ f"entry_point: plugin.py\n"
230
+ f"hooks: []\n"
231
+ )
232
+ (plugin_dir / "plugin.yaml").write_text(yaml_content, encoding="utf-8")
233
+ print(f" ✓ Created {name}/plugin.yaml")
234
+
235
+ # --- plugin.py ---
236
+ if args.template == "advanced":
237
+ plugin_py = (
238
+ f'"""Advanced {name} plugin."""\n\n'
239
+ f"from __future__ import annotations\n\n"
240
+ f"from typing import Any\n\n\n"
241
+ f"class {base_name}:\n"
242
+ f' """{name} plugin."""\n\n'
243
+ f" @property\n"
244
+ f" def name(self) -> str:\n"
245
+ f' return "{name}"\n\n'
246
+ f" @property\n"
247
+ f" def version(self) -> str:\n"
248
+ f' return "0.1.0"\n\n'
249
+ f" @property\n"
250
+ f" def description(self) -> str:\n"
251
+ f' return "{description}"\n\n'
252
+ f" def initialize(self, context: dict[str, Any]) -> None:\n"
253
+ f' """Called when plugin is loaded."""\n'
254
+ f" pass\n\n"
255
+ f" def shutdown(self) -> None:\n"
256
+ f' """Called when plugin is unloaded."""\n'
257
+ f" pass\n\n"
258
+ f" def register_commands(self, registry: Any) -> None:\n"
259
+ f' """Register CLI commands."""\n'
260
+ f" pass\n\n"
261
+ f" def register_hooks(self, registry: Any) -> None:\n"
262
+ f' """Register hooks."""\n'
263
+ f' registry.register_hook("post_execute", self.on_post_execute)\n\n'
264
+ f" def on_post_execute(self, result: Any) -> None:\n"
265
+ f" pass\n"
266
+ )
267
+ else:
268
+ plugin_py = (
269
+ f'"""{name} plugin."""\n\n\n'
270
+ f"class {base_name}:\n"
271
+ f" @property\n"
272
+ f" def name(self):\n"
273
+ f' return "{name}"\n'
274
+ f"\n"
275
+ f" @property\n"
276
+ f" def version(self):\n"
277
+ f' return "0.1.0"\n'
278
+ f"\n"
279
+ f" @property\n"
280
+ f" def description(self):\n"
281
+ f' return "{description}"\n'
282
+ )
283
+ (plugin_dir / "plugin.py").write_text(plugin_py, encoding="utf-8")
284
+ print(f" ✓ Created {name}/plugin.py")
285
+
286
+ # --- README.md ---
287
+ readme = (
288
+ f"# {name}\n\n"
289
+ f"{description}\n\n"
290
+ f"## Installation\n\n"
291
+ f"```bash\nopencontext plugin install {name}\n```\n\n"
292
+ f"## Usage\n\n"
293
+ f"Describe how to use this plugin.\n"
294
+ )
295
+ (plugin_dir / "README.md").write_text(readme, encoding="utf-8")
296
+ print(f" ✓ Created {name}/README.md")
297
+
298
+ print(f"\n Plugin '{name}' scaffolded. Edit plugin.py to add your logic.\n")
299
+
300
+
183
301
  def _plugin_install(args: Any) -> None:
184
302
  """Install a plugin."""
185
303
 
@@ -289,6 +407,46 @@ def _plugin_info(args: Any) -> None:
289
407
  registry = PluginRegistry()
290
408
  info = registry.get_info(args.name)
291
409
 
410
+ # Check registry for latest version
411
+ latest_version = "unknown"
412
+ try:
413
+ fetcher = RegistryFetcher()
414
+ entry = fetcher.get(args.name)
415
+ if entry and entry.versions:
416
+ latest_version = entry.versions[0].version
417
+ except Exception:
418
+ pass
419
+
420
+ if args.json:
421
+ if info is None:
422
+ data = {
423
+ "name": args.name,
424
+ "installed": False,
425
+ "latest": latest_version,
426
+ }
427
+ else:
428
+ data = {
429
+ "name": info.name,
430
+ "installed": True,
431
+ "version": info.version,
432
+ "latest": latest_version,
433
+ "description": info.description,
434
+ "author": info.author,
435
+ "homepage": info.homepage,
436
+ "repository": info.repository,
437
+ "enabled": info.enabled,
438
+ "install_source": info.install_source,
439
+ "source_url": info.source_url,
440
+ "entry_point": info.entry_point,
441
+ "installed_at": info.installed_at,
442
+ "updated_at": info.updated_at,
443
+ "hooks": info.hooks,
444
+ }
445
+ if latest_version != "unknown" and latest_version != info.version:
446
+ data["update_available"] = True
447
+ print(json.dumps(data, indent=2))
448
+ return
449
+
292
450
  if info is None:
293
451
  # Check registry
294
452
  fetcher = RegistryFetcher()
@@ -311,11 +469,15 @@ def _plugin_info(args: Any) -> None:
311
469
  print(f"\n {info.name}")
312
470
  print(f" {'─' * len(info.name)}")
313
471
  print(f" Version: {info.version}")
472
+ if latest_version != "unknown" and latest_version != info.version:
473
+ print(f" Latest: {latest_version} (update available)")
474
+ else:
475
+ print(f" Latest: {latest_version}")
314
476
  print(f" Description: {info.description}")
315
477
  print(f" Author: {info.author or '—'}")
316
478
  print(f" Homepage: {info.homepage or '—'}")
317
479
  print(f" Repository: {info.repository or '—'}")
318
- print(f" Status: {'enabled' if info.enabled else 'disabled'}")
480
+ print(f" Status: {'enabled' if info.enabled else 'disabled'}")
319
481
  print(f" Source: {info.install_source}")
320
482
  print(f" Source URL: {info.source_url or '—'}")
321
483
  print(f" Entry point: {info.entry_point}")