vimgym 0.1.1__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.
vimgym/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
vimgym/cli.py ADDED
@@ -0,0 +1,639 @@
1
+ """Vimgym CLI — AI session memory for developers."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json as _json
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ from vimgym import __version__
11
+
12
+
13
+ def _make_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="vg",
16
+ description="Vimgym — AI session memory for developers",
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"vimgym {__version__}")
19
+ parser.add_argument("--verbose", action="store_true", help="Verbose output")
20
+
21
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
22
+
23
+ sub.add_parser("init", help="Initialize vault and detect AI tool sources")
24
+
25
+ start_p = sub.add_parser("start", help="Start daemon (watcher + web server)")
26
+ start_p.add_argument(
27
+ "--no-browser",
28
+ action="store_true",
29
+ dest="no_browser",
30
+ help="Do not open the browser on start (use for background services)",
31
+ )
32
+
33
+ sub.add_parser("stop", help="Stop daemon")
34
+ sub.add_parser("status", help="Show daemon status and vault stats")
35
+ sub.add_parser("open", help="Open browser UI")
36
+ sub.add_parser("doctor", help="Run system diagnostics")
37
+
38
+ search_p = sub.add_parser("search", help="Search sessions")
39
+ search_p.add_argument("query", nargs="?", help="Search query")
40
+ search_p.add_argument("--project", help="Filter by project name")
41
+ search_p.add_argument("--branch", help="Filter by git branch")
42
+ search_p.add_argument("--since", help="Filter by date (ISO or Nd: 7d, 30d)")
43
+ search_p.add_argument("--limit", type=int, default=20)
44
+ search_p.add_argument("--json", action="store_true", dest="as_json")
45
+
46
+ config_p = sub.add_parser("config", help="View or modify configuration")
47
+ config_sub = config_p.add_subparsers(dest="config_cmd", metavar="SUBCOMMAND")
48
+ sources_p = config_sub.add_parser("sources", help="List configured AI tool sources")
49
+ sources_p.add_argument("source_id", nargs="?", help="Source id to enable/disable")
50
+ sources_p.add_argument("--enable", action="store_true")
51
+ sources_p.add_argument("--disable", action="store_true")
52
+
53
+ return parser
54
+
55
+
56
+ def main() -> None:
57
+ parser = _make_parser()
58
+ args = parser.parse_args()
59
+
60
+ if args.command is None:
61
+ parser.print_help()
62
+ sys.exit(0)
63
+
64
+ cmd = args.command
65
+ if cmd == "init":
66
+ sys.exit(_cmd_init())
67
+ if cmd == "start":
68
+ sys.exit(_cmd_start(args))
69
+ if cmd == "stop":
70
+ sys.exit(_cmd_stop())
71
+ if cmd == "status":
72
+ sys.exit(_cmd_status())
73
+ if cmd == "open":
74
+ sys.exit(_cmd_open())
75
+ if cmd == "doctor":
76
+ sys.exit(_cmd_doctor())
77
+ if cmd == "search":
78
+ sys.exit(_cmd_search(args))
79
+ if cmd == "config":
80
+ sys.exit(_cmd_config(args))
81
+
82
+ parser.print_help()
83
+ sys.exit(1)
84
+
85
+
86
+ # ───────────────────────── command handlers ─────────────────────────
87
+
88
+
89
+ def _console():
90
+ from rich.console import Console
91
+ return Console()
92
+
93
+
94
+ def _load_cfg():
95
+ from vimgym.config import load_config
96
+ return load_config()
97
+
98
+
99
+ def _cmd_init() -> int:
100
+ """Initialize the vault, detect sources, persist config."""
101
+ from vimgym.config import init_vault
102
+
103
+ console = _console()
104
+ cfg = _load_cfg()
105
+ cfg, newly_detected = init_vault(cfg)
106
+
107
+ console.print(f"[green]✓ vault initialized[/green] {cfg.vault_dir}")
108
+ if not cfg.sources:
109
+ console.print("[yellow]⚠ no AI tool directories detected in $HOME[/yellow]")
110
+ console.print(" Edit ~/.vimgym/config.json to add a source manually.")
111
+ return 0
112
+
113
+ console.print()
114
+ console.print("[bold]Detected sources:[/bold]")
115
+ for s in cfg.sources:
116
+ if s.enabled:
117
+ mark, status, color = "✓", "enabled", "green"
118
+ note = ""
119
+ elif s.type == "claude_code":
120
+ mark, status, color = "⊘", "disabled", "yellow"
121
+ note = ""
122
+ else:
123
+ mark, status, color = "⊘", "disabled", "yellow"
124
+ note = " parser coming in v2"
125
+ console.print(
126
+ f" [{color}]{mark}[/{color}] {s.name:<20} {s.path:<30} [{color}][{status}][/{color}]{note}"
127
+ )
128
+
129
+ console.print()
130
+ console.print("Next: [cyan]vg start[/cyan]")
131
+ return 0
132
+
133
+
134
+ def _cmd_start(args: argparse.Namespace | None = None) -> int:
135
+ from vimgym.config import init_vault
136
+ from vimgym.daemon import is_running, start_daemon
137
+
138
+ console = _console()
139
+ cfg = _load_cfg()
140
+
141
+ if args is not None and getattr(args, "no_browser", False):
142
+ cfg.auto_open_browser = False
143
+
144
+ _warn_if_ephemeral_install(console)
145
+
146
+ # Auto-init on first run.
147
+ if not cfg.sources or not (cfg.vault_dir / "config.json").exists():
148
+ cfg, newly = init_vault(cfg)
149
+ if newly:
150
+ console.print(f"[dim]auto-initialized vault, detected {len(newly)} source(s)[/dim]")
151
+
152
+ if is_running(cfg):
153
+ console.print(f"[yellow]vimgym already running[/yellow] on http://{cfg.server_host}:{cfg.server_port}")
154
+ return 0
155
+
156
+ try:
157
+ pid = start_daemon(cfg)
158
+ except RuntimeError as e:
159
+ console.print(f"[red]start failed:[/red] {e}")
160
+ return 1
161
+
162
+ console.print(f"[green]vimgym started[/green] (pid {pid}) on http://{cfg.server_host}:{cfg.server_port}")
163
+ console.print(f" vault: {cfg.vault_dir}")
164
+ enabled = cfg.enabled_sources
165
+ if enabled:
166
+ for s in enabled:
167
+ console.print(f" watching: {s.expanded_path} [dim]({s.id})[/dim]")
168
+ else:
169
+ console.print(" [yellow]watching: no enabled sources[/yellow]")
170
+
171
+ if cfg.auto_open_browser:
172
+ try:
173
+ webbrowser.open(f"http://{cfg.server_host}:{cfg.server_port}")
174
+ except Exception:
175
+ pass
176
+ return 0
177
+
178
+
179
+ def _warn_if_ephemeral_install(console) -> None:
180
+ """Warn the user if `vg` lives in a project venv that won't survive shell restart.
181
+
182
+ Skips the warning for installs in well-known persistent locations:
183
+ /opt/homebrew, /usr/local, /usr/bin, ~/.local/bin, pipx venvs.
184
+ """
185
+ import shutil
186
+
187
+ vg_path = shutil.which("vg")
188
+ if not vg_path:
189
+ return
190
+
191
+ persistent_prefixes = (
192
+ "/opt/homebrew/",
193
+ "/usr/local/",
194
+ "/usr/bin/",
195
+ "/home/linuxbrew/",
196
+ str(Path("~/.local/").expanduser()) + "/",
197
+ str(Path("~/.local/pipx/").expanduser()) + "/",
198
+ str(Path("~/Library/Python/").expanduser()) + "/",
199
+ )
200
+ if any(vg_path.startswith(p) for p in persistent_prefixes):
201
+ return
202
+
203
+ if ".venv" in vg_path or "/venv/" in vg_path:
204
+ console.print()
205
+ console.print("[yellow]⚠ vg is running from a project virtualenv[/yellow]")
206
+ console.print(f" [dim]{vg_path}[/dim]")
207
+ console.print(" This shell session needs `source .venv/bin/activate` after every restart.")
208
+ console.print(" For a permanent install:")
209
+ console.print(" [cyan]brew install shoaibrain/vimgym/vimgym[/cyan] (macOS)")
210
+ console.print(" [cyan]pipx install vimgym[/cyan] (any OS)")
211
+ console.print()
212
+
213
+
214
+ def _cmd_stop() -> int:
215
+ from vimgym.daemon import stop_daemon
216
+
217
+ console = _console()
218
+ cfg = _load_cfg()
219
+ if stop_daemon(cfg):
220
+ console.print("[green]vimgym stopped[/green]")
221
+ return 0
222
+ console.print("[yellow]vimgym was not running[/yellow]")
223
+ return 0
224
+
225
+
226
+ def _cmd_status() -> int:
227
+ from rich.table import Table
228
+
229
+ from vimgym.daemon import get_pid, is_running
230
+ from vimgym.db import get_connection
231
+ from vimgym.storage.queries import get_stats
232
+
233
+ console = _console()
234
+ cfg = _load_cfg()
235
+ running = is_running(cfg)
236
+ pid = get_pid(cfg)
237
+
238
+ table = Table(show_header=False, box=None, padding=(0, 2))
239
+ table.add_row("status", "[green]running[/green]" if running else "[red]stopped[/red]")
240
+ if running and pid is not None:
241
+ table.add_row("pid", str(pid))
242
+ table.add_row("url", f"http://{cfg.server_host}:{cfg.server_port}")
243
+ table.add_row("vault", str(cfg.vault_dir))
244
+ table.add_row("watching", str(cfg.watch_path))
245
+
246
+ if cfg.db_path.exists():
247
+ try:
248
+ conn = get_connection(cfg.db_path)
249
+ stats = get_stats(conn)
250
+ table.add_row("sessions", str(stats.total_sessions))
251
+ table.add_row("db size", f"{stats.db_size_bytes / 1024 / 1024:.1f} MB")
252
+ except Exception as e:
253
+ table.add_row("db", f"[red]error: {e}[/red]")
254
+ else:
255
+ table.add_row("db", "(not initialized)")
256
+
257
+ console.print(table)
258
+ return 0
259
+
260
+
261
+ def _cmd_open() -> int:
262
+ from vimgym.daemon import is_running
263
+
264
+ console = _console()
265
+ cfg = _load_cfg()
266
+ if not is_running(cfg):
267
+ console.print("[red]vimgym is not running[/red] — try `vg start`")
268
+ return 1
269
+ url = f"http://{cfg.server_host}:{cfg.server_port}"
270
+ webbrowser.open(url)
271
+ console.print(f"opening {url}")
272
+ return 0
273
+
274
+
275
+ # ───────────────────────── doctor ─────────────────────────
276
+
277
+
278
+ def _cmd_doctor() -> int:
279
+ """System diagnostic. Exit 0 if all green, 1 if any red issues."""
280
+ import shutil
281
+ import sqlite3
282
+
283
+ console = _console()
284
+ cfg = _load_cfg()
285
+
286
+ OK = "[green]✓[/green]"
287
+ WARN = "[yellow]⊘[/yellow]"
288
+ FAIL = "[red]✗[/red]"
289
+
290
+ issues: list[str] = []
291
+
292
+ console.print()
293
+ console.print(" [bold]vimgym doctor[/bold] — system check")
294
+ console.print()
295
+
296
+ # ── environment ──
297
+ console.print(" [dim]environment[/dim]")
298
+ console.print(f" {OK} vimgym [cyan]{__version__}[/cyan]")
299
+
300
+ py = sys.version_info
301
+ py_str = f"{py.major}.{py.minor}.{py.micro}"
302
+ if (py.major, py.minor) >= (3, 11):
303
+ console.print(f" {OK} Python {py_str} (>=3.11 required)")
304
+ else:
305
+ console.print(f" {FAIL} Python {py_str} (>=3.11 required)")
306
+ issues.append(f"Python {py_str} is too old; install 3.11 or newer.")
307
+
308
+ sqlite_ver = sqlite3.sqlite_version
309
+ fts5_ok = False
310
+ try:
311
+ conn = sqlite3.connect(":memory:")
312
+ conn.execute("CREATE VIRTUAL TABLE _t USING fts5(x)")
313
+ conn.execute("DROP TABLE _t")
314
+ conn.close()
315
+ fts5_ok = True
316
+ except sqlite3.OperationalError:
317
+ pass
318
+ if fts5_ok:
319
+ console.print(f" {OK} SQLite {sqlite_ver} with FTS5")
320
+ else:
321
+ console.print(f" {FAIL} SQLite {sqlite_ver} — FTS5 NOT available")
322
+ issues.append(
323
+ "SQLite FTS5 is not enabled in this Python build. "
324
+ "Reinstall Python via Homebrew: brew install python@3.12"
325
+ )
326
+
327
+ vg_path = shutil.which("vg")
328
+ if vg_path:
329
+ console.print(f" {OK} vg binary [dim]{vg_path}[/dim]")
330
+ if ".venv" in vg_path or "/venv/" in vg_path:
331
+ console.print(
332
+ f" {WARN} vg lives inside a virtualenv — won't survive shell restart"
333
+ )
334
+ else:
335
+ console.print(f" {WARN} vg binary not on $PATH (running from module?)")
336
+
337
+ console.print()
338
+
339
+ # ── vault ──
340
+ console.print(" [dim]vault[/dim]")
341
+ if cfg.vault_dir.exists():
342
+ try:
343
+ mode = oct(cfg.vault_dir.stat().st_mode & 0o777)
344
+ console.print(f" {OK} vault dir [cyan]{cfg.vault_dir}[/cyan] ({mode})")
345
+ except OSError as e:
346
+ console.print(f" {FAIL} vault dir {cfg.vault_dir} ({e})")
347
+ issues.append(f"Cannot stat vault dir: {e}")
348
+ else:
349
+ console.print(f" {WARN} vault dir does not exist [dim]{cfg.vault_dir}[/dim]")
350
+ console.print(" Run [cyan]vg init[/cyan] to create it.")
351
+
352
+ if cfg.db_path.exists():
353
+ try:
354
+ mode = cfg.db_path.stat().st_mode & 0o777
355
+ mode_str = oct(mode)
356
+ if mode == 0o600:
357
+ console.print(f" {OK} vault.db ({mode_str})")
358
+ else:
359
+ console.print(f" {WARN} vault.db permissions are {mode_str}, expected 0o600")
360
+ issues.append(f"vault.db has permissions {mode_str}; expected 0o600")
361
+ except OSError as e:
362
+ console.print(f" {FAIL} vault.db ({e})")
363
+ issues.append(f"Cannot stat vault.db: {e}")
364
+
365
+ try:
366
+ conn = sqlite3.connect(cfg.db_path)
367
+ n_sessions = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
368
+ schema_v_row = conn.execute(
369
+ "SELECT value FROM config WHERE key='schema_version'"
370
+ ).fetchone()
371
+ schema_v = schema_v_row[0] if schema_v_row else "?"
372
+ conn.close()
373
+ console.print(f" {OK} schema v{schema_v}, {n_sessions} sessions indexed")
374
+ except Exception as e:
375
+ console.print(f" {FAIL} cannot read vault.db: {e}")
376
+ issues.append(f"vault.db is unreadable: {e}")
377
+ else:
378
+ console.print(f" {WARN} vault.db not yet created")
379
+
380
+ try:
381
+ usage = shutil.disk_usage(cfg.vault_dir if cfg.vault_dir.exists() else Path.home())
382
+ free_mb = usage.free // (1024 * 1024)
383
+ if free_mb < 100:
384
+ console.print(f" {FAIL} disk free: {free_mb} MB (low)")
385
+ issues.append(f"Less than 100 MB free on the vault disk ({free_mb} MB).")
386
+ else:
387
+ console.print(f" {OK} disk free: {free_mb} MB")
388
+ except OSError:
389
+ pass
390
+
391
+ console.print()
392
+
393
+ # ── daemon ──
394
+ console.print(" [dim]daemon[/dim]")
395
+ from vimgym.daemon import get_pid, is_running
396
+ if is_running(cfg):
397
+ console.print(f" {OK} daemon running (pid {get_pid(cfg)})")
398
+ console.print(f" [dim]http://{cfg.server_host}:{cfg.server_port}[/dim]")
399
+ else:
400
+ console.print(f" {WARN} daemon not running — start with [cyan]vg start[/cyan]")
401
+
402
+ console.print()
403
+
404
+ # ── sources ──
405
+ console.print(" [dim]sources[/dim]")
406
+ if not cfg.sources:
407
+ console.print(f" {WARN} no sources configured — run [cyan]vg init[/cyan]")
408
+ for s in cfg.sources:
409
+ exists = s.exists()
410
+ parser_avail = s.type == "claude_code"
411
+
412
+ if s.enabled and exists and parser_avail:
413
+ icon = OK
414
+ note = "enabled"
415
+ elif s.enabled and not exists:
416
+ icon = FAIL
417
+ note = "enabled but path missing"
418
+ issues.append(f"Source '{s.id}' is enabled but path does not exist: {s.path}")
419
+ elif not parser_avail:
420
+ icon = WARN
421
+ note = "parser coming v2"
422
+ else:
423
+ icon = WARN
424
+ note = "disabled"
425
+
426
+ console.print(
427
+ f" {icon} {s.id:<14} [dim]{s.path}[/dim] ({note})"
428
+ )
429
+
430
+ console.print()
431
+
432
+ # ── redaction ──
433
+ console.print(" [dim]redaction[/dim]")
434
+ try:
435
+ from vimgym.pipeline.redact import RedactionEngine
436
+ engine = RedactionEngine(cfg.rules_path)
437
+ if engine.rule_count > 0:
438
+ source = "vault" if cfg.rules_path.exists() else "bundled defaults"
439
+ console.print(f" {OK} {engine.rule_count} patterns loaded ({source})")
440
+ else:
441
+ console.print(f" {FAIL} no redaction rules loaded")
442
+ issues.append("No redaction rules available — secrets will not be stripped from sessions.")
443
+ except Exception as e:
444
+ console.print(f" {FAIL} redaction engine failed: {e}")
445
+ issues.append(f"Redaction engine error: {e}")
446
+
447
+ console.print()
448
+
449
+ # ── summary ──
450
+ if issues:
451
+ console.print(f" [red]{len(issues)} issue(s) found:[/red]")
452
+ for i, msg in enumerate(issues, 1):
453
+ console.print(f" {i}. {msg}")
454
+ console.print()
455
+ return 1
456
+
457
+ console.print(" [green]no issues found[/green]")
458
+ console.print()
459
+ return 0
460
+
461
+
462
+ def _cmd_search(args: argparse.Namespace) -> int:
463
+ if not args.query:
464
+ print("error: search requires a query", file=sys.stderr)
465
+ return 2
466
+
467
+ from vimgym.daemon import is_running
468
+
469
+ cfg = _load_cfg()
470
+
471
+ if is_running(cfg):
472
+ results = _search_via_api(cfg, args)
473
+ else:
474
+ results = _search_via_db(cfg, args)
475
+
476
+ if args.as_json:
477
+ print(_json.dumps(results, indent=2, default=str))
478
+ return 0
479
+
480
+ _print_search_table(results, args.query)
481
+ return 0
482
+
483
+
484
+ def _search_via_api(cfg, args: argparse.Namespace) -> list[dict]:
485
+ import httpx
486
+
487
+ params: dict = {"q": args.query, "limit": args.limit}
488
+ if args.project:
489
+ params["project"] = args.project
490
+ if args.branch:
491
+ params["branch"] = args.branch
492
+ if args.since:
493
+ params["since"] = args.since
494
+
495
+ url = f"http://{cfg.server_host}:{cfg.server_port}/api/search"
496
+ try:
497
+ r = httpx.get(url, params=params, timeout=5.0)
498
+ r.raise_for_status()
499
+ except Exception as e:
500
+ print(f"warning: API request failed ({e}); falling back to direct DB", file=sys.stderr)
501
+ return _search_via_db(cfg, args)
502
+ return r.json().get("results", [])
503
+
504
+
505
+ def _search_via_db(cfg, args: argparse.Namespace) -> list[dict]:
506
+ if not cfg.db_path.exists():
507
+ print("error: vault not initialized — run `vg start` once first", file=sys.stderr)
508
+ return []
509
+ from vimgym.db import get_connection
510
+ from vimgym.storage.queries import search_sessions
511
+
512
+ conn = get_connection(cfg.db_path)
513
+ results = search_sessions(
514
+ conn,
515
+ args.query,
516
+ project=args.project,
517
+ branch=args.branch,
518
+ since=args.since,
519
+ limit=args.limit,
520
+ )
521
+ return [
522
+ {
523
+ "session_uuid": r.session_uuid,
524
+ "project_name": r.project_name,
525
+ "ai_title": r.ai_title,
526
+ "started_at": r.started_at,
527
+ "duration_secs": r.duration_secs,
528
+ "git_branch": r.git_branch,
529
+ "snippet": r.snippet,
530
+ }
531
+ for r in results
532
+ ]
533
+
534
+
535
+ def _print_search_table(results: list[dict], query: str) -> None:
536
+ from rich.table import Table
537
+
538
+ console = _console()
539
+ if not results:
540
+ console.print(f"[yellow]no results for[/yellow] {query!r}")
541
+ return
542
+
543
+ table = Table(title=f"Results for {query!r}", show_lines=False)
544
+ table.add_column("DATE", style="dim")
545
+ table.add_column("ID")
546
+ table.add_column("PROJECT")
547
+ table.add_column("BRANCH", style="cyan")
548
+ table.add_column("DUR", justify="right")
549
+ table.add_column("TITLE")
550
+
551
+ for r in results:
552
+ date = (r.get("started_at") or "")[:10]
553
+ uid = (r.get("session_uuid") or "")[:8]
554
+ proj = r.get("project_name") or ""
555
+ branch = r.get("git_branch") or ""
556
+ dur = r.get("duration_secs")
557
+ dur_str = f"{int(dur)//60}m" if dur else "-"
558
+ title = (r.get("ai_title") or "")[:60]
559
+ table.add_row(date, uid, proj, branch, dur_str, title)
560
+
561
+ console.print(table)
562
+
563
+
564
+ def _cmd_config(args: argparse.Namespace) -> int:
565
+ from vimgym.config import save_config
566
+
567
+ console = _console()
568
+ cfg = _load_cfg()
569
+
570
+ sub = getattr(args, "config_cmd", None)
571
+ if sub == "sources":
572
+ return _cmd_config_sources(args, cfg, console, save_config)
573
+
574
+ # Default `vg config` — print active config summary.
575
+ from rich.table import Table
576
+ table = Table(show_header=False, box=None, padding=(0, 2))
577
+ table.add_row("vault", str(cfg.vault_dir))
578
+ table.add_row("server", f"{cfg.server_host}:{cfg.server_port}")
579
+ table.add_row("logs", str(cfg.log_path))
580
+ table.add_row("rules", str(cfg.rules_path))
581
+ table.add_row("sources",f"{len(cfg.sources)} configured, {len(cfg.enabled_sources)} active")
582
+ console.print(table)
583
+ console.print()
584
+ console.print("[dim]Run [cyan]vg config sources[/cyan] for source details.[/dim]")
585
+ return 0
586
+
587
+
588
+ def _cmd_config_sources(args: argparse.Namespace, cfg, console, save_config) -> int:
589
+ from rich.table import Table
590
+
591
+ target_id = getattr(args, "source_id", None)
592
+ enable = getattr(args, "enable", False)
593
+ disable = getattr(args, "disable", False)
594
+
595
+ if target_id and (enable or disable):
596
+ for s in cfg.sources:
597
+ if s.id == target_id:
598
+ s.enabled = bool(enable)
599
+ save_config(cfg)
600
+ state = "[green]enabled[/green]" if enable else "[yellow]disabled[/yellow]"
601
+ console.print(f"source [cyan]{target_id}[/cyan] is now {state}")
602
+ console.print("[dim](takes effect on next [cyan]vg start[/cyan])[/dim]")
603
+ return 0
604
+ console.print(f"[red]no source with id '{target_id}'[/red]")
605
+ return 1
606
+
607
+ if target_id and not (enable or disable):
608
+ console.print("[yellow]use --enable or --disable[/yellow]")
609
+ return 2
610
+
611
+ if not cfg.sources:
612
+ console.print("[yellow]no sources configured.[/yellow] Run [cyan]vg init[/cyan] to detect them.")
613
+ return 0
614
+
615
+ table = Table(title="vimgym sources", title_style="bold cyan")
616
+ table.add_column("id", style="cyan")
617
+ table.add_column("path")
618
+ table.add_column("status", justify="center")
619
+ table.add_column("parser")
620
+
621
+ for s in cfg.sources:
622
+ if s.enabled and s.exists():
623
+ status = "[green]● on[/green]"
624
+ elif s.enabled:
625
+ status = "[yellow]● on (path missing)[/yellow]"
626
+ else:
627
+ status = "[dim]○ off[/dim]"
628
+ parser_state = "[green]available[/green]" if s.type == "claude_code" else "[yellow]coming v2[/yellow]"
629
+ table.add_row(s.id, s.path, status, parser_state)
630
+
631
+ console.print(table)
632
+ console.print()
633
+ console.print("[dim]To enable: [cyan]vg config sources <id> --enable[/cyan][/dim]")
634
+ console.print("[dim]Note: Only claude_code parser is available in v1.[/dim]")
635
+ return 0
636
+
637
+
638
+ if __name__ == "__main__":
639
+ main()