logics-manager 2.3.2__tar.gz → 2.4.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 (64) hide show
  1. {logics_manager-2.3.2 → logics_manager-2.4.0}/PKG-INFO +1 -1
  2. {logics_manager-2.3.2 → logics_manager-2.4.0}/README.md +1 -1
  3. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/viewer.py +213 -11
  4. logics_manager-2.4.0/logics_manager/viewer_assets/media/css/board.css +658 -0
  5. logics_manager-2.4.0/logics_manager/viewer_assets/media/css/details.css +457 -0
  6. logics_manager-2.4.0/logics_manager/viewer_assets/media/css/layout.css +123 -0
  7. logics_manager-2.4.0/logics_manager/viewer_assets/media/css/toolbar.css +614 -0
  8. logics_manager-2.4.0/logics_manager/viewer_assets/media/harnessApi.js +324 -0
  9. logics_manager-2.4.0/logics_manager/viewer_assets/media/hostApi.js +213 -0
  10. logics_manager-2.4.0/logics_manager/viewer_assets/media/hostApiContract.js +55 -0
  11. logics_manager-2.4.0/logics_manager/viewer_assets/media/icon.png +0 -0
  12. logics_manager-2.4.0/logics_manager/viewer_assets/media/layoutController.js +246 -0
  13. logics_manager-2.4.0/logics_manager/viewer_assets/media/logics.svg +7 -0
  14. logics_manager-2.4.0/logics_manager/viewer_assets/media/logicsModel.js +910 -0
  15. logics_manager-2.4.0/logics_manager/viewer_assets/media/main.css +112 -0
  16. logics_manager-2.4.0/logics_manager/viewer_assets/media/main.js +3 -0
  17. logics_manager-2.4.0/logics_manager/viewer_assets/media/mainApp.js +1005 -0
  18. logics_manager-2.4.0/logics_manager/viewer_assets/media/mainCore.js +604 -0
  19. logics_manager-2.4.0/logics_manager/viewer_assets/media/mainInteractionHandlers.js +324 -0
  20. logics_manager-2.4.0/logics_manager/viewer_assets/media/mainInteractions.js +378 -0
  21. logics_manager-2.4.0/logics_manager/viewer_assets/media/renderBoard.js +3 -0
  22. logics_manager-2.4.0/logics_manager/viewer_assets/media/renderBoardApp.js +1339 -0
  23. logics_manager-2.4.0/logics_manager/viewer_assets/media/renderDetails.js +685 -0
  24. logics_manager-2.4.0/logics_manager/viewer_assets/media/renderMarkdown.js +449 -0
  25. logics_manager-2.4.0/logics_manager/viewer_assets/media/toolsPanelLayout.js +172 -0
  26. logics_manager-2.4.0/logics_manager/viewer_assets/media/uiStatus.js +54 -0
  27. logics_manager-2.4.0/logics_manager/viewer_assets/media/webviewChrome.js +428 -0
  28. logics_manager-2.4.0/logics_manager/viewer_assets/media/webviewPersistence.js +116 -0
  29. logics_manager-2.4.0/logics_manager/viewer_assets/media/webviewSelectors.js +492 -0
  30. logics_manager-2.4.0/logics_manager/viewer_assets/vendor/mermaid.min.js +3405 -0
  31. logics_manager-2.4.0/logics_manager/viewer_assets/viewer/browser-host.js +1266 -0
  32. logics_manager-2.4.0/logics_manager/viewer_assets/viewer/index.html +223 -0
  33. logics_manager-2.4.0/logics_manager/viewer_assets/viewer/viewer.css +574 -0
  34. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager.egg-info/PKG-INFO +1 -1
  35. logics_manager-2.4.0/logics_manager.egg-info/SOURCES.txt +60 -0
  36. logics_manager-2.4.0/pyproject.toml +30 -0
  37. logics_manager-2.3.2/logics_manager.egg-info/SOURCES.txt +0 -30
  38. logics_manager-2.3.2/pyproject.toml +0 -15
  39. {logics_manager-2.3.2 → logics_manager-2.4.0}/LICENSE +0 -0
  40. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/__init__.py +0 -0
  41. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/__main__.py +0 -0
  42. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/assist.py +0 -0
  43. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/assist_handoff.py +0 -0
  44. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/assist_surface.py +0 -0
  45. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/audit.py +0 -0
  46. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/bootstrap.py +0 -0
  47. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/cli.py +0 -0
  48. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/cli_output.py +0 -0
  49. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/config.py +0 -0
  50. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/doctor.py +0 -0
  51. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/flow.py +0 -0
  52. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/flow_evidence.py +0 -0
  53. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/index.py +0 -0
  54. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/insights.py +0 -0
  55. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/lint.py +0 -0
  56. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/mcp.py +0 -0
  57. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/path_utils.py +0 -0
  58. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/sync.py +0 -0
  59. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/termstyle.py +0 -0
  60. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager/update_check.py +0 -0
  61. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager.egg-info/dependency_links.txt +0 -0
  62. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager.egg-info/entry_points.txt +0 -0
  63. {logics_manager-2.3.2 → logics_manager-2.4.0}/logics_manager.egg-info/top_level.txt +0 -0
  64. {logics_manager-2.3.2 → logics_manager-2.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logics-manager
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: Canonical Logics CLI
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexAgo83/logics-manager/actions/workflows/ci.yml)
4
4
  [![License](https://img.shields.io/github/license/AlexAgo83/logics-manager)](LICENSE)
5
- ![Version](https://img.shields.io/badge/version-v2.3.2-4C8BF5)
5
+ ![Version](https://img.shields.io/badge/version-v2.4.0-4C8BF5)
6
6
  ![VS Code](https://img.shields.io/badge/VS%20Code-1.86.0-007ACC?logo=visualstudiocode&logoColor=white)
7
7
  ![TypeScript](https://img.shields.io/badge/TypeScript-5.3.3-3178C6?logo=typescript&logoColor=white)
8
8
  ![Vitest](https://img.shields.io/badge/Vitest-2.1.8-6E9F18?logo=vitest&logoColor=white)
@@ -5,6 +5,8 @@ import json
5
5
  import mimetypes
6
6
  import os
7
7
  import re
8
+ import shutil
9
+ import socket
8
10
  import subprocess
9
11
  import sys
10
12
  import webbrowser
@@ -40,9 +42,15 @@ DOC_FAMILIES = (
40
42
 
41
43
  STAGE_ORDER = {family.stage: index for index, family in enumerate(DOC_FAMILIES)}
42
44
  REPO_ROOT = Path(__file__).resolve().parents[1]
45
+ PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
43
46
  VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
47
+ if not (VIEWER_ROOT / "index.html").is_file():
48
+ VIEWER_ROOT = PACKAGE_VIEWER_ASSETS_ROOT / "viewer"
44
49
  SHARED_MEDIA_ROOT = REPO_ROOT / "clients" / "shared-web" / "media"
50
+ if not SHARED_MEDIA_ROOT.is_dir():
51
+ SHARED_MEDIA_ROOT = PACKAGE_VIEWER_ASSETS_ROOT / "media"
45
52
  DIST_VENDOR_ROOT = REPO_ROOT / "dist" / "vendor"
53
+ PACKAGE_VENDOR_ROOT = PACKAGE_VIEWER_ASSETS_ROOT / "vendor"
46
54
  NODE_MERMAID_ROOT = REPO_ROOT / "node_modules" / "mermaid" / "dist"
47
55
 
48
56
 
@@ -322,9 +330,16 @@ def collect_viewer_items(repo_root: Path) -> list[dict[str, Any]]:
322
330
  return items
323
331
 
324
332
 
325
- def viewer_data_payload(repo_root: Path, selected_id: str | None = None) -> dict[str, Any]:
333
+ def viewer_data_payload(
334
+ repo_root: Path,
335
+ selected_id: str | None = None,
336
+ *,
337
+ auto_refresh_interval_seconds: int = 60,
338
+ ) -> dict[str, Any]:
326
339
  return {
327
340
  "root": str(repo_root.resolve()),
341
+ "repoName": repo_root.resolve().name,
342
+ "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
328
343
  "items": collect_viewer_items(repo_root),
329
344
  "updateInfo": get_update_info(_current_version()).to_payload(),
330
345
  "selectedId": selected_id,
@@ -378,13 +393,122 @@ def _system_editor_command(path: Path) -> list[str]:
378
393
  return ["xdg-open", str(path)]
379
394
 
380
395
 
396
+ def _run_read_only_git(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
397
+ command = ["git", *args]
398
+ git_runner = runner or subprocess.run
399
+ return git_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
400
+
401
+
402
+ def _sanitize_git_ref(value: str) -> str:
403
+ ref = value.strip()
404
+ ref = re.sub(r"://[^/@\s]+@", "://", ref)
405
+ ref = re.sub(r"^[^/@\s]+@", "", ref)
406
+ return ref[:200]
407
+
408
+
409
+ def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
410
+ if not line or line.startswith("## "):
411
+ return None
412
+ if line.startswith("?? "):
413
+ return "untracked", {"path": line[3:].strip()}
414
+ if len(line) < 4:
415
+ return None
416
+ staged = line[0]
417
+ worktree = line[1]
418
+ raw_path = line[3:].strip()
419
+ if " -> " in raw_path:
420
+ before, after = raw_path.split(" -> ", 1)
421
+ return "renamed", {"path": after.strip(), "from": before.strip()}
422
+ if staged == "R":
423
+ return "renamed", {"path": raw_path}
424
+ if staged not in {" ", "?", "!"}:
425
+ return "staged", {"path": raw_path, "code": staged}
426
+ if worktree == "D":
427
+ return "deleted", {"path": raw_path, "code": worktree}
428
+ if worktree not in {" ", "?", "!"}:
429
+ return "modified", {"path": raw_path, "code": worktree}
430
+ return None
431
+
432
+
433
+ def _parse_git_branch_line(line: str) -> dict[str, Any]:
434
+ branch = line[3:].strip() if line.startswith("## ") else ""
435
+ tracking = ""
436
+ ahead = 0
437
+ behind = 0
438
+ if "..." in branch:
439
+ branch, tracking_part = branch.split("...", 1)
440
+ if " [" in tracking_part:
441
+ tracking, details = tracking_part.split(" [", 1)
442
+ for detail in details.rstrip("]").split(", "):
443
+ if detail.startswith("ahead "):
444
+ ahead = int(detail.removeprefix("ahead ") or "0")
445
+ if detail.startswith("behind "):
446
+ behind = int(detail.removeprefix("behind ") or "0")
447
+ else:
448
+ tracking = tracking_part
449
+ return {
450
+ "branch": _sanitize_git_ref(branch or "HEAD"),
451
+ "tracking": _sanitize_git_ref(tracking),
452
+ "ahead": ahead,
453
+ "behind": behind,
454
+ }
455
+
456
+
457
+ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
458
+ git_which = which or shutil.which
459
+ if not git_which("git"):
460
+ return {"state": "unavailable", "message": "Git is not available on PATH."}
461
+ try:
462
+ inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
463
+ except (OSError, subprocess.SubprocessError) as exc:
464
+ return {"state": "error", "message": f"Unable to run Git status: {exc}"}
465
+ if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
466
+ return {"state": "not-repository", "message": "This folder is not inside a Git worktree."}
467
+
468
+ try:
469
+ status = _run_read_only_git(repo_root, ["status", "--porcelain=v1", "-b"], runner=runner)
470
+ commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
471
+ except (OSError, subprocess.SubprocessError) as exc:
472
+ return {"state": "error", "message": f"Unable to collect Git status: {exc}"}
473
+ if status.returncode != 0:
474
+ message = (status.stderr or status.stdout or "Git status failed.").strip().splitlines()[0]
475
+ return {"state": "error", "message": message}
476
+
477
+ lines = status.stdout.splitlines()
478
+ branch_info = _parse_git_branch_line(lines[0]) if lines else {"branch": "HEAD", "tracking": "", "ahead": 0, "behind": 0}
479
+ groups: dict[str, list[dict[str, str]]] = {key: [] for key in ("staged", "modified", "deleted", "renamed", "untracked")}
480
+ for line in lines[1:]:
481
+ classified = _classify_porcelain_entry(line)
482
+ if classified:
483
+ group, entry = classified
484
+ groups[group].append(entry)
485
+ counts = {key: len(value) for key, value in groups.items()}
486
+ dirty = any(counts.values())
487
+ return {
488
+ "state": "ok",
489
+ **branch_info,
490
+ "clean": not dirty,
491
+ "dirty": dirty,
492
+ "counts": counts,
493
+ "groups": groups,
494
+ "latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
495
+ }
496
+
497
+
381
498
  def _json_bytes(payload: Any) -> bytes:
382
499
  return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
383
500
 
384
501
 
385
502
  class LogicsViewerServer(ThreadingHTTPServer):
386
- def __init__(self, server_address: tuple[str, int], repo_root: Path):
503
+ def __init__(
504
+ self,
505
+ server_address: tuple[str, int],
506
+ repo_root: Path,
507
+ *,
508
+ auto_refresh_interval_seconds: int = 60,
509
+ ):
387
510
  self.repo_root = repo_root.resolve()
511
+ self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
388
512
  super().__init__(server_address, LogicsViewerRequestHandler)
389
513
 
390
514
 
@@ -435,6 +559,8 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
435
559
  vendor_path = DIST_VENDOR_ROOT / "mermaid.min.js"
436
560
  if not vendor_path.is_file():
437
561
  vendor_path = NODE_MERMAID_ROOT / "mermaid.min.js"
562
+ if not vendor_path.is_file():
563
+ vendor_path = PACKAGE_VENDOR_ROOT / "mermaid.min.js"
438
564
  self._serve_file(vendor_path)
439
565
  return
440
566
  if route.startswith("/media/"):
@@ -445,7 +571,15 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
445
571
  self._serve_file(media_path)
446
572
  return
447
573
  if route == "/api/items":
448
- self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
574
+ self._send_json(
575
+ {
576
+ "ok": True,
577
+ "payload": viewer_data_payload(
578
+ self.server.repo_root,
579
+ auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
580
+ ),
581
+ }
582
+ )
449
583
  return
450
584
  if route == "/api/doc":
451
585
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
@@ -460,12 +594,23 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
460
594
  if route == "/api/audit":
461
595
  self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
462
596
  return
597
+ if route == "/api/git-status":
598
+ self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
599
+ return
463
600
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
464
601
 
465
602
  def do_POST(self) -> None:
466
603
  parsed = urlparse(self.path)
467
604
  if parsed.path == "/api/refresh":
468
- self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
605
+ self._send_json(
606
+ {
607
+ "ok": True,
608
+ "payload": viewer_data_payload(
609
+ self.server.repo_root,
610
+ auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
611
+ ),
612
+ }
613
+ )
469
614
  return
470
615
  if parsed.path == "/api/edit":
471
616
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
@@ -479,19 +624,52 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
479
624
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
480
625
 
481
626
 
482
- def create_viewer_server(repo_root: Path, host: str = "127.0.0.1", port: int = 8765) -> LogicsViewerServer:
483
- return LogicsViewerServer((host, port), repo_root)
627
+ def create_viewer_server(
628
+ repo_root: Path,
629
+ host: str = "127.0.0.1",
630
+ port: int = 8765,
631
+ *,
632
+ auto_refresh_interval_seconds: int = 60,
633
+ ) -> LogicsViewerServer:
634
+ return LogicsViewerServer(
635
+ (host, port),
636
+ repo_root,
637
+ auto_refresh_interval_seconds=auto_refresh_interval_seconds,
638
+ )
484
639
 
485
640
 
486
- def render_start_status(url: str, repo_root: Path, *, focus: str | None = None) -> str:
641
+ def _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
642
+ if host not in {"0.0.0.0", "::", ""}:
643
+ return None
644
+ try:
645
+ candidate = socket.gethostbyname(socket.gethostname())
646
+ except OSError:
647
+ return None
648
+ if not candidate or candidate.startswith("127."):
649
+ return None
650
+ return build_viewer_url(candidate, port, focus=focus, read=read)
651
+
652
+
653
+ def render_start_status(
654
+ url: str,
655
+ repo_root: Path,
656
+ *,
657
+ focus: str | None = None,
658
+ network_url: str | None = None,
659
+ bind_host: str = "localhost",
660
+ auto_refresh_interval_seconds: int = 60,
661
+ ) -> str:
487
662
  lines = [
488
663
  "Logics viewer running:",
489
- url,
664
+ f"Local: {url}",
490
665
  "",
491
666
  f"Repo: {repo_root.name}",
492
667
  "Mode: read-only",
493
- "Bind: localhost",
668
+ f"Bind: {bind_host}",
669
+ f"Auto refresh: {auto_refresh_interval_seconds}s",
494
670
  ]
671
+ if network_url:
672
+ lines.insert(2, f"Network: {network_url}")
495
673
  if focus:
496
674
  lines.append(f"Focus: {focus}")
497
675
  return "\n".join(lines)
@@ -501,6 +679,12 @@ def build_parser() -> argparse.ArgumentParser:
501
679
  parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
502
680
  parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
503
681
  parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
682
+ parser.add_argument(
683
+ "--refresh-interval",
684
+ type=int,
685
+ default=60,
686
+ help="Automatic refresh interval in seconds. Defaults to 60; positive shorter intervals are allowed.",
687
+ )
504
688
  parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
505
689
  parser.add_argument("--read", action="store_true", help="Open the focused item in the read preview. Requires --focus.")
506
690
  parser.add_argument("--open", action="store_true", help="Open the viewer in the default browser.")
@@ -511,16 +695,34 @@ def build_parser() -> argparse.ArgumentParser:
511
695
  def main(argv: list[str]) -> int:
512
696
  args = build_parser().parse_args(argv)
513
697
  repo_root = find_repo_root(Path.cwd())
698
+ if args.refresh_interval <= 0:
699
+ raise SystemExit("--refresh-interval must be a positive number of seconds.")
514
700
  if args.read and not args.focus:
515
701
  raise SystemExit("--read requires --focus.")
516
702
  try:
517
703
  focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
518
704
  except ValueError as exc:
519
705
  raise SystemExit(str(exc)) from exc
520
- server = create_viewer_server(repo_root, host=args.host, port=args.port)
706
+ server = create_viewer_server(
707
+ repo_root,
708
+ host=args.host,
709
+ port=args.port,
710
+ auto_refresh_interval_seconds=args.refresh_interval,
711
+ )
521
712
  host, port = server.server_address[:2]
522
713
  url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
523
- print(render_start_status(url, repo_root, focus=focus), flush=True)
714
+ network_url = _network_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
715
+ print(
716
+ render_start_status(
717
+ url,
718
+ repo_root,
719
+ focus=focus,
720
+ network_url=network_url,
721
+ bind_host=str(host),
722
+ auto_refresh_interval_seconds=args.refresh_interval,
723
+ ),
724
+ flush=True,
725
+ )
524
726
  if args.open and not args.no_open:
525
727
  webbrowser.open(url)
526
728