@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.
- package/README.md +12 -3
- package/VERSION +1 -1
- package/clients/shared-web/media/renderMarkdown.js +1 -1
- package/clients/viewer/browser-host.js +884 -22
- package/clients/viewer/index.html +21 -5
- package/clients/viewer/viewer.css +547 -2
- package/logics_manager/audit.py +2 -2
- package/logics_manager/cli.py +7 -1
- package/logics_manager/insights.py +1 -1
- package/logics_manager/lint.py +2 -2
- package/logics_manager/mcp.py +10 -2
- package/logics_manager/viewer.py +192 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/mcp.py
CHANGED
|
@@ -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("}")
|
package/logics_manager/viewer.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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=
|
|
686
|
-
help="Automatic refresh interval in seconds. Defaults to
|
|
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.
|
|
5
|
+
"version": "2.5.1",
|
|
6
6
|
"publisher": "cdx-logics",
|
|
7
7
|
"icon": "clients/shared-web/media/icon.png",
|
|
8
8
|
"repository": {
|