@grifhinz/logics-manager 2.4.0 → 2.5.1

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.
@@ -427,7 +427,7 @@ def _markdown_file_path(repo_root: Path, raw_path: str, allowed_dirs: tuple[str,
427
427
 
428
428
  def _run_command(repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
429
429
  command = [sys.executable, "-m", "logics_manager", *args]
430
- result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
430
+ result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=_subprocess_env())
431
431
  if result.returncode != 0:
432
432
  raise McpToolError(
433
433
  "command_failed",
@@ -439,7 +439,7 @@ def _run_command(repo_root: Path, args: list[str]) -> subprocess.CompletedProces
439
439
 
440
440
  def _run_json_command(repo_root: Path, args: list[str]) -> dict[str, Any]:
441
441
  command = [sys.executable, "-m", "logics_manager", *args]
442
- result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
442
+ result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=_subprocess_env())
443
443
  payload = _json_from_stdout_or_none(result.stdout)
444
444
  if payload is None:
445
445
  raise McpToolError(
@@ -450,6 +450,14 @@ def _run_json_command(repo_root: Path, args: list[str]) -> dict[str, Any]:
450
450
  return payload
451
451
 
452
452
 
453
+ def _subprocess_env() -> dict[str, str]:
454
+ env = os.environ.copy()
455
+ source_root = str(Path(__file__).resolve().parents[1])
456
+ existing = env.get("PYTHONPATH")
457
+ env["PYTHONPATH"] = source_root if not existing else os.pathsep.join([source_root, existing])
458
+ return env
459
+
460
+
453
461
  def _json_from_stdout(stdout: str) -> dict[str, Any]:
454
462
  start = stdout.find("{")
455
463
  end = stdout.rfind("}")
@@ -14,6 +14,7 @@ from dataclasses import dataclass
14
14
  from datetime import datetime
15
15
  from http import HTTPStatus
16
16
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+ from importlib import metadata
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
  from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
@@ -56,8 +57,14 @@ NODE_MERMAID_ROOT = REPO_ROOT / "node_modules" / "mermaid" / "dist"
56
57
 
57
58
  def _current_version() -> str:
58
59
  try:
59
- return (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
60
+ version = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip()
60
61
  except OSError:
62
+ version = ""
63
+ if version:
64
+ return version
65
+ try:
66
+ return metadata.version("logics-manager")
67
+ except metadata.PackageNotFoundError:
61
68
  return "0.0.0"
62
69
 
63
70
 
@@ -334,7 +341,7 @@ def viewer_data_payload(
334
341
  repo_root: Path,
335
342
  selected_id: str | None = None,
336
343
  *,
337
- auto_refresh_interval_seconds: int = 60,
344
+ auto_refresh_interval_seconds: int = 15,
338
345
  ) -> dict[str, Any]:
339
346
  return {
340
347
  "root": str(repo_root.resolve()),
@@ -399,6 +406,20 @@ def _run_read_only_git(repo_root: Path, args: list[str], *, runner: Any | None =
399
406
  return git_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
400
407
 
401
408
 
409
+ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
410
+ command = ["cdx", *args]
411
+ cdx_runner = runner or subprocess.run
412
+ return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
413
+
414
+
415
+ def _logics_doc_type(rel_path: str) -> str:
416
+ normalized = rel_path.replace("\\", "/").lstrip("/")
417
+ for family in DOC_FAMILIES:
418
+ if normalized.startswith(f"{family.directory}/"):
419
+ return family.stage
420
+ return ""
421
+
422
+
402
423
  def _sanitize_git_ref(value: str) -> str:
403
424
  ref = value.strip()
404
425
  ref = re.sub(r"://[^/@\s]+@", "://", ref)
@@ -410,7 +431,8 @@ def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
410
431
  if not line or line.startswith("## "):
411
432
  return None
412
433
  if line.startswith("?? "):
413
- return "untracked", {"path": line[3:].strip()}
434
+ path = line[3:].strip()
435
+ return "untracked", {"path": path, "logicsType": _logics_doc_type(path)}
414
436
  if len(line) < 4:
415
437
  return None
416
438
  staged = line[0]
@@ -418,15 +440,16 @@ def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
418
440
  raw_path = line[3:].strip()
419
441
  if " -> " in raw_path:
420
442
  before, after = raw_path.split(" -> ", 1)
421
- return "renamed", {"path": after.strip(), "from": before.strip()}
443
+ path = after.strip()
444
+ return "renamed", {"path": path, "from": before.strip(), "logicsType": _logics_doc_type(path)}
422
445
  if staged == "R":
423
- return "renamed", {"path": raw_path}
446
+ return "renamed", {"path": raw_path, "logicsType": _logics_doc_type(raw_path)}
424
447
  if staged not in {" ", "?", "!"}:
425
- return "staged", {"path": raw_path, "code": staged}
448
+ return "staged", {"path": raw_path, "code": staged, "logicsType": _logics_doc_type(raw_path)}
426
449
  if worktree == "D":
427
- return "deleted", {"path": raw_path, "code": worktree}
450
+ return "deleted", {"path": raw_path, "code": worktree, "logicsType": _logics_doc_type(raw_path)}
428
451
  if worktree not in {" ", "?", "!"}:
429
- return "modified", {"path": raw_path, "code": worktree}
452
+ return "modified", {"path": raw_path, "code": worktree, "logicsType": _logics_doc_type(raw_path)}
430
453
  return None
431
454
 
432
455
 
@@ -454,6 +477,59 @@ def _parse_git_branch_line(line: str) -> dict[str, Any]:
454
477
  }
455
478
 
456
479
 
480
+ def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
481
+ commits: list[dict[str, str]] = []
482
+ for line in output.splitlines():
483
+ parts = line.split("\x1f")
484
+ if len(parts) < 5:
485
+ continue
486
+ commit_hash, subject, author, date, refs = parts[:5]
487
+ commits.append(
488
+ {
489
+ "hash": _sanitize_git_ref(commit_hash),
490
+ "subject": subject.strip()[:240],
491
+ "author": author.strip()[:120],
492
+ "date": date.strip()[:40],
493
+ "refs": _sanitize_git_ref(refs),
494
+ }
495
+ )
496
+ return commits
497
+
498
+
499
+ def _count_unique_git_status_paths(groups: dict[str, list[dict[str, str]]]) -> int:
500
+ paths: set[str] = set()
501
+ for entries in groups.values():
502
+ for entry in entries:
503
+ path = entry.get("path", "").strip()
504
+ if path:
505
+ paths.add(path)
506
+ return len(paths)
507
+
508
+
509
+ def _git_unpushed_commit_count(repo_root: Path, *, runner: Any | None = None) -> dict[str, Any]:
510
+ try:
511
+ upstream = _run_read_only_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], runner=runner)
512
+ except (OSError, subprocess.SubprocessError) as exc:
513
+ return {"available": False, "count": 0, "message": f"Unable to inspect upstream: {exc}"}
514
+ if upstream.returncode != 0:
515
+ return {"available": False, "count": 0, "message": "No upstream branch detected."}
516
+
517
+ tracking = _sanitize_git_ref(upstream.stdout.strip())
518
+ try:
519
+ unpushed = _run_read_only_git(repo_root, ["rev-list", "--count", "@{u}..HEAD"], runner=runner)
520
+ except (OSError, subprocess.SubprocessError) as exc:
521
+ return {"available": False, "count": 0, "tracking": tracking, "message": f"Unable to count unpushed commits: {exc}"}
522
+ if unpushed.returncode != 0:
523
+ message = (unpushed.stderr or unpushed.stdout or "Unable to count unpushed commits.").strip().splitlines()[0]
524
+ return {"available": False, "count": 0, "tracking": tracking, "message": message}
525
+
526
+ try:
527
+ count = max(0, int(unpushed.stdout.strip() or "0"))
528
+ except ValueError:
529
+ count = 0
530
+ return {"available": True, "count": count, "tracking": tracking, "message": ""}
531
+
532
+
457
533
  def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
458
534
  git_which = which or shutil.which
459
535
  if not git_which("git"):
@@ -468,6 +544,12 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
468
544
  try:
469
545
  status = _run_read_only_git(repo_root, ["status", "--porcelain=v1", "-b"], runner=runner)
470
546
  commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
547
+ recent_commits = _run_read_only_git(
548
+ repo_root,
549
+ ["log", "-8", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
550
+ runner=runner,
551
+ )
552
+ unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
471
553
  except (OSError, subprocess.SubprocessError) as exc:
472
554
  return {"state": "error", "message": f"Unable to collect Git status: {exc}"}
473
555
  if status.returncode != 0:
@@ -483,6 +565,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
483
565
  group, entry = classified
484
566
  groups[group].append(entry)
485
567
  counts = {key: len(value) for key, value in groups.items()}
568
+ uncommitted_files = _count_unique_git_status_paths(groups)
486
569
  dirty = any(counts.values())
487
570
  return {
488
571
  "state": "ok",
@@ -490,11 +573,98 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
490
573
  "clean": not dirty,
491
574
  "dirty": dirty,
492
575
  "counts": counts,
576
+ "badgeCounts": {
577
+ "unpushedCommits": int(unpushed.get("count", 0)),
578
+ "uncommittedFiles": uncommitted_files,
579
+ },
580
+ "badgeAvailability": {
581
+ "unpushedCommits": bool(unpushed.get("available")),
582
+ "uncommittedFiles": True,
583
+ },
584
+ "badgeMessages": {
585
+ "unpushedCommits": str(unpushed.get("message", "")),
586
+ "uncommittedFiles": "",
587
+ },
493
588
  "groups": groups,
494
589
  "latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
590
+ "recentCommits": _parse_recent_git_commits(recent_commits.stdout) if recent_commits.returncode == 0 else [],
591
+ }
592
+
593
+
594
+ def git_diff_payload(
595
+ repo_root: Path,
596
+ rel_path: str,
597
+ *,
598
+ cached: bool = False,
599
+ max_chars: int = 20000,
600
+ runner: Any | None = None,
601
+ which: Any | None = None,
602
+ ) -> dict[str, Any]:
603
+ git_which = which or shutil.which
604
+ if not git_which("git"):
605
+ return {"state": "unavailable", "message": "Git is not available on PATH."}
606
+ normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
607
+ if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
608
+ return {"state": "error", "message": "Unsafe Git path."}
609
+ try:
610
+ inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
611
+ except (OSError, subprocess.SubprocessError) as exc:
612
+ return {"state": "error", "message": f"Unable to run Git diff: {exc}"}
613
+ if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
614
+ return {"state": "not-repository", "message": "This folder is not inside a Git worktree."}
615
+
616
+ args = ["diff", "--no-ext-diff", "--unified=80"]
617
+ if cached:
618
+ args.append("--cached")
619
+ args.extend(["--", normalized])
620
+ try:
621
+ diff = _run_read_only_git(repo_root, args, runner=runner)
622
+ except (OSError, subprocess.SubprocessError) as exc:
623
+ return {"state": "error", "message": f"Unable to collect Git diff: {exc}"}
624
+ if diff.returncode != 0:
625
+ message = (diff.stderr or diff.stdout or "Git diff failed.").strip().splitlines()[0]
626
+ return {"state": "error", "message": message}
627
+ content = diff.stdout
628
+ truncated = len(content) > max_chars
629
+ if truncated:
630
+ content = content[:max_chars]
631
+ return {
632
+ "state": "ok",
633
+ "path": normalized,
634
+ "mode": "staged" if cached else "worktree",
635
+ "diff": content,
636
+ "truncated": truncated,
637
+ "logicsType": _logics_doc_type(normalized),
638
+ "message": "" if content else "No diff is available for this file in the selected mode.",
495
639
  }
496
640
 
497
641
 
642
+ def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
643
+ cdx_which = which or shutil.which
644
+ if not cdx_which("cdx"):
645
+ return {"state": "unavailable", "message": "CDX is not available on PATH.", "status": {}}
646
+
647
+ try:
648
+ status = _run_read_only_cdx(repo_root, ["status", "--json"], runner=runner)
649
+ except subprocess.TimeoutExpired:
650
+ return {"state": "timeout", "message": "CDX status timed out.", "status": {}}
651
+ except (OSError, subprocess.SubprocessError) as exc:
652
+ return {"state": "error", "message": f"Unable to run CDX status: {exc}", "status": {}}
653
+
654
+ if status.returncode != 0:
655
+ message = (status.stderr or status.stdout or "CDX status failed.").strip().splitlines()[0]
656
+ return {"state": "error", "message": message, "status": {}}
657
+
658
+ try:
659
+ parsed = json.loads(status.stdout or "{}")
660
+ except json.JSONDecodeError:
661
+ return {"state": "invalid-json", "message": "CDX status returned invalid JSON.", "status": {}}
662
+ if not isinstance(parsed, dict):
663
+ return {"state": "invalid-json", "message": "CDX status JSON must be an object.", "status": {}}
664
+
665
+ return {"state": "ok", "message": "", "status": parsed}
666
+
667
+
498
668
  def _json_bytes(payload: Any) -> bytes:
499
669
  return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
500
670
 
@@ -505,7 +675,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
505
675
  server_address: tuple[str, int],
506
676
  repo_root: Path,
507
677
  *,
508
- auto_refresh_interval_seconds: int = 60,
678
+ auto_refresh_interval_seconds: int = 15,
509
679
  ):
510
680
  self.repo_root = repo_root.resolve()
511
681
  self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
@@ -597,6 +767,15 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
597
767
  if route == "/api/git-status":
598
768
  self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
599
769
  return
770
+ if route == "/api/cdx-status":
771
+ self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
772
+ return
773
+ if route == "/api/git-diff":
774
+ params = parse_qs(parsed.query)
775
+ rel_path = params.get("path", [""])[0]
776
+ cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
777
+ self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
778
+ return
600
779
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
601
780
 
602
781
  def do_POST(self) -> None:
@@ -629,7 +808,7 @@ def create_viewer_server(
629
808
  host: str = "127.0.0.1",
630
809
  port: int = 8765,
631
810
  *,
632
- auto_refresh_interval_seconds: int = 60,
811
+ auto_refresh_interval_seconds: int = 15,
633
812
  ) -> LogicsViewerServer:
634
813
  return LogicsViewerServer(
635
814
  (host, port),
@@ -657,7 +836,7 @@ def render_start_status(
657
836
  focus: str | None = None,
658
837
  network_url: str | None = None,
659
838
  bind_host: str = "localhost",
660
- auto_refresh_interval_seconds: int = 60,
839
+ auto_refresh_interval_seconds: int = 15,
661
840
  ) -> str:
662
841
  lines = [
663
842
  "Logics viewer running:",
@@ -682,8 +861,8 @@ def build_parser() -> argparse.ArgumentParser:
682
861
  parser.add_argument(
683
862
  "--refresh-interval",
684
863
  type=int,
685
- default=60,
686
- help="Automatic refresh interval in seconds. Defaults to 60; positive shorter intervals are allowed.",
864
+ default=15,
865
+ help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
687
866
  )
688
867
  parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
689
868
  parser.add_argument("--read", action="store_true", help="Open the focused item in the read preview. Requires --focus.")
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@grifhinz/logics-manager",
3
3
  "displayName": "Logics Orchestrator",
4
4
  "description": "Visual orchestration for Logics workflows inside VS Code.",
5
- "version": "2.4.0",
5
+ "version": "2.5.1",
6
6
  "publisher": "cdx-logics",
7
7
  "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.4.0"
7
+ version = "2.5.1"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10