@grifhinz/logics-manager 2.2.0 → 2.3.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.
Files changed (43) hide show
  1. package/README.md +95 -1
  2. package/VERSION +1 -1
  3. package/clients/README.md +9 -0
  4. package/clients/shared-web/media/css/board.css +658 -0
  5. package/clients/shared-web/media/css/details.css +457 -0
  6. package/clients/shared-web/media/css/layout.css +123 -0
  7. package/clients/shared-web/media/css/toolbar.css +576 -0
  8. package/clients/shared-web/media/harnessApi.js +324 -0
  9. package/clients/shared-web/media/hostApi.js +213 -0
  10. package/clients/shared-web/media/hostApiContract.js +55 -0
  11. package/clients/shared-web/media/icon.png +0 -0
  12. package/clients/shared-web/media/layoutController.js +246 -0
  13. package/clients/shared-web/media/logics.svg +7 -0
  14. package/clients/shared-web/media/logicsModel.js +910 -0
  15. package/clients/shared-web/media/main.css +112 -0
  16. package/clients/shared-web/media/main.js +3 -0
  17. package/clients/shared-web/media/mainApp.js +1005 -0
  18. package/clients/shared-web/media/mainCore.js +604 -0
  19. package/clients/shared-web/media/mainInteractionHandlers.js +324 -0
  20. package/clients/shared-web/media/mainInteractions.js +378 -0
  21. package/clients/shared-web/media/renderBoard.js +3 -0
  22. package/clients/shared-web/media/renderBoardApp.js +1339 -0
  23. package/clients/shared-web/media/renderDetails.js +685 -0
  24. package/clients/shared-web/media/renderMarkdown.js +449 -0
  25. package/clients/shared-web/media/toolsPanelLayout.js +172 -0
  26. package/clients/shared-web/media/uiStatus.js +54 -0
  27. package/clients/shared-web/media/webviewChrome.js +405 -0
  28. package/clients/shared-web/media/webviewPersistence.js +116 -0
  29. package/clients/shared-web/media/webviewSelectors.js +491 -0
  30. package/clients/viewer/README.md +5 -0
  31. package/clients/viewer/browser-host.js +847 -0
  32. package/clients/viewer/index.html +237 -0
  33. package/clients/viewer/viewer.css +433 -0
  34. package/logics_manager/assist.py +9 -142
  35. package/logics_manager/assist_handoff.py +132 -0
  36. package/logics_manager/assist_surface.py +38 -0
  37. package/logics_manager/cli.py +78 -5
  38. package/logics_manager/flow.py +126 -24
  39. package/logics_manager/flow_evidence.py +63 -0
  40. package/logics_manager/update_check.py +138 -0
  41. package/logics_manager/viewer.py +533 -0
  42. package/package.json +12 -6
  43. package/pyproject.toml +1 -1
@@ -11,6 +11,8 @@ import subprocess
11
11
  from shutil import which
12
12
  from typing import Any
13
13
 
14
+ from .assist_handoff import build_handoff as _build_handoff
15
+ from .assist_surface import build_changed_surface_summary as _build_changed_surface_summary
14
16
  from .config import ConfigError, find_repo_root, load_repo_config
15
17
  from .doctor import doctor_payload
16
18
  from .lint import lint_payload
@@ -643,35 +645,6 @@ def _git_changed_paths(repo_root: Path) -> list[str]:
643
645
  return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
644
646
 
645
647
 
646
- def _git_lines(repo_root: Path, args: list[str]) -> list[str]:
647
- try:
648
- completed = subprocess.run(
649
- ["git", *args],
650
- cwd=repo_root,
651
- stdout=subprocess.PIPE,
652
- stderr=subprocess.DEVNULL,
653
- text=True,
654
- check=False,
655
- )
656
- except OSError:
657
- return []
658
- if completed.returncode != 0:
659
- return []
660
- return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
661
-
662
-
663
- def _git_range_changed_paths(repo_root: Path, since: str) -> list[str]:
664
- return sorted(set(_git_lines(repo_root, ["diff", "--name-only", "--relative=.", f"{since}..HEAD"])))
665
-
666
-
667
- def _git_range_commits(repo_root: Path, since: str) -> list[dict[str, str]]:
668
- commits: list[dict[str, str]] = []
669
- for line in _git_lines(repo_root, ["log", "--oneline", f"{since}..HEAD"]):
670
- commit, _, subject = line.partition(" ")
671
- commits.append({"commit": commit, "subject": subject})
672
- return commits
673
-
674
-
675
648
  def _is_low_risk_generated_path(path: str) -> bool:
676
649
  normalized = path.strip().replace("\\", "/")
677
650
  filename = normalized.rsplit("/", 1)[-1]
@@ -754,7 +727,7 @@ def _render_diff_risk_text(payload: dict[str, object]) -> str:
754
727
  def _summarize_commit_scope(changed_paths: list[str]) -> tuple[str, str]:
755
728
  if not changed_paths:
756
729
  return "root", "No changes detected; nothing to commit."
757
- if any(path.startswith("src/") for path in changed_paths):
730
+ if any(path.startswith("clients/vscode/src/") or path.startswith("clients/shared-web/media/") for path in changed_paths):
758
731
  return "plugin", "Plugin surface changes detected."
759
732
  if any(path.startswith("logics_manager/") for path in changed_paths):
760
733
  return "python-runtime", "Native Logics manager changes detected."
@@ -796,55 +769,20 @@ def _build_commit_plan(changed_paths: list[str]) -> dict[str, object]:
796
769
  }
797
770
 
798
771
 
799
- def _build_changed_surface_summary(changed_paths: list[str]) -> dict[str, object]:
800
- category_counter: Counter[str] = Counter()
801
- for path in changed_paths:
802
- normalized = path.replace("\\", "/")
803
- if normalized.startswith("src/"):
804
- category_counter["plugin"] += 1
805
- elif normalized.startswith("logics_manager/"):
806
- category_counter["python-runtime"] += 1
807
- elif normalized.startswith("logics/"):
808
- category_counter["workflow-docs"] += 1
809
- elif normalized.startswith("tests/") or "/tests/" in normalized or normalized.startswith("python_tests/"):
810
- category_counter["tests"] += 1
811
- elif normalized.endswith(".md"):
812
- category_counter["docs"] += 1
813
- else:
814
- category_counter["other"] += 1
815
- primary = category_counter.most_common(1)[0][0] if category_counter else "clean"
816
- summary = {
817
- "clean": "No changed surface was detected.",
818
- "plugin": "The plugin surface is the dominant change area.",
819
- "python-runtime": "The native Python runtime is the dominant change area.",
820
- "workflow-docs": "Workflow documentation is the dominant change area.",
821
- "tests": "Tests are the dominant change area.",
822
- "docs": "Markdown documentation is the dominant change area.",
823
- "other": "Mixed repository changes are present.",
824
- }.get(primary, "Mixed repository changes are present.")
825
- return {
826
- "summary": summary,
827
- "primary_category": primary,
828
- "counts": dict(sorted(category_counter.items())),
829
- "changed_paths": changed_paths,
830
- "review_recommended": primary not in {"clean", "docs"} and bool(changed_paths),
831
- }
832
-
833
-
834
772
  def _build_validation_checklist(changed_paths: list[str]) -> dict[str, object]:
835
773
  surface = _build_changed_surface_summary(changed_paths)
836
774
  checks: list[str] = [
837
- "Run `python3 -m pytest python_tests/test_logics_manager_cli.py -q`.",
775
+ "Run `python3 -m pytest tests/python/test_logics_manager_cli.py -q`.",
838
776
  "Run `python3 -m compileall logics_manager`.",
839
777
  "Run `npm run lint:logics`.",
840
778
  ]
841
- if any(path.startswith("src/") for path in changed_paths):
779
+ if any(path.startswith("clients/vscode/src/") or path.startswith("clients/shared-web/media/") for path in changed_paths):
842
780
  checks.append("Run the plugin test suite that exercises the VS Code entrypoints.")
843
781
  if any(path.startswith("logics_manager/") for path in changed_paths):
844
782
  checks.append("Smoke-test `python3 -m logics_manager --help` and the affected native subcommands.")
845
783
  if any(path.startswith("logics/") for path in changed_paths):
846
784
  checks.append("Run `python3 -m logics_manager lint --require-status` and inspect the workflow docs manually.")
847
- if any(path.startswith("tests/") or path.startswith("python_tests/") for path in changed_paths):
785
+ if any(path.startswith("tests/") or path.startswith("tests/python/") for path in changed_paths):
848
786
  checks.append("Run the focused affected tests before broad regression sweeps.")
849
787
  if not changed_paths:
850
788
  checks.append("No validation needed beyond a clean smoke check; there are no tracked changes.")
@@ -860,15 +798,15 @@ def _build_test_impact_summary(changed_paths: list[str]) -> dict[str, object]:
860
798
  categories = _build_changed_surface_summary(changed_paths)["counts"]
861
799
  recommended: list[str] = []
862
800
  if "python-runtime" in categories:
863
- recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
801
+ recommended.append("python3 -m pytest tests/python/test_logics_manager_cli.py -q")
864
802
  if "plugin" in categories:
865
803
  recommended.append("npm run lint")
866
804
  if "workflow-docs" in categories:
867
805
  recommended.append("npm run lint:logics")
868
806
  if "tests" in categories:
869
- recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
807
+ recommended.append("python3 -m pytest tests/python/test_logics_manager_cli.py -q")
870
808
  if not recommended:
871
- recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
809
+ recommended.append("python3 -m pytest tests/python/test_logics_manager_cli.py -q")
872
810
  return {
873
811
  "summary": "Recommended test order derived from the current change surface.",
874
812
  "categories": categories,
@@ -2079,77 +2017,6 @@ def _build_closure_summary(repo_root: Path, ref: str | None) -> dict[str, object
2079
2017
  }
2080
2018
 
2081
2019
 
2082
- def _doc_title_from_path(path: Path) -> str:
2083
- try:
2084
- lines = path.read_text(encoding="utf-8").splitlines()
2085
- except OSError:
2086
- return path.stem
2087
- for line in lines:
2088
- if line.startswith("## "):
2089
- payload = line.removeprefix("## ").strip()
2090
- if " - " in payload:
2091
- return payload.split(" - ", 1)[1].strip()
2092
- return payload
2093
- return path.stem
2094
-
2095
-
2096
- def _validation_lines_from_task(path: Path) -> list[str]:
2097
- try:
2098
- lines = path.read_text(encoding="utf-8").splitlines()
2099
- except OSError:
2100
- return []
2101
- values: list[str] = []
2102
- for line in _section_lines(lines, "Validation"):
2103
- stripped = line.strip()
2104
- if not stripped.startswith("- "):
2105
- continue
2106
- value = stripped[2:].strip()
2107
- if value and not value.lower().startswith("run `") and not value.lower().startswith("run the "):
2108
- values.append(value)
2109
- return values
2110
-
2111
-
2112
- def _build_handoff(repo_root: Path, since: str) -> dict[str, object]:
2113
- changed_paths = _git_range_changed_paths(repo_root, since)
2114
- commits = _git_range_commits(repo_root, since)
2115
- surface = _build_changed_surface_summary(changed_paths)
2116
- logics_docs: list[dict[str, object]] = []
2117
- validations: list[str] = []
2118
- for rel_path in changed_paths:
2119
- if not rel_path.startswith("logics/") or not rel_path.endswith(".md"):
2120
- continue
2121
- path = repo_root / rel_path
2122
- kind = path.parent.name
2123
- entry = {
2124
- "path": rel_path,
2125
- "ref": path.stem,
2126
- "kind": kind,
2127
- "title": _doc_title_from_path(path),
2128
- "status": _doc_status(path) if path.is_file() else "Unknown",
2129
- }
2130
- logics_docs.append(entry)
2131
- if kind == "tasks":
2132
- validations.extend(_validation_lines_from_task(path))
2133
- next_actions = [
2134
- "Run lint/audit if not already included in validation evidence.",
2135
- "Review changed files before committing or handing off.",
2136
- ]
2137
- if any(path.startswith("logics_manager/") for path in changed_paths):
2138
- next_actions.append("Run `PYTHONPATH=\"$PWD\" pytest python_tests -q` for Python CLI changes.")
2139
- if any(path.startswith("src/") for path in changed_paths):
2140
- next_actions.append("Run the TypeScript/vitest checks for extension changes.")
2141
- return {
2142
- "since": since,
2143
- "commit_count": len(commits),
2144
- "commits": commits,
2145
- "changed_paths": changed_paths,
2146
- "surface": surface,
2147
- "logics_docs": logics_docs,
2148
- "validations": sorted(set(validations)),
2149
- "next_actions": next_actions,
2150
- }
2151
-
2152
-
2153
2020
  def _build_context_pack(repo_root: Path, seed_ref: str, *, mode: str, profile: str) -> dict[str, object]:
2154
2021
  docs = _workflow_docs(repo_root)
2155
2022
  selected: list[Path] = []
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import subprocess
5
+
6
+ from .assist_surface import build_changed_surface_summary
7
+
8
+
9
+ def _git_lines(repo_root: Path, args: list[str]) -> list[str]:
10
+ try:
11
+ completed = subprocess.run(
12
+ ["git", *args],
13
+ cwd=repo_root,
14
+ stdout=subprocess.PIPE,
15
+ stderr=subprocess.DEVNULL,
16
+ text=True,
17
+ check=False,
18
+ )
19
+ except OSError:
20
+ return []
21
+ if completed.returncode != 0:
22
+ return []
23
+ return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
24
+
25
+
26
+ def _git_range_changed_paths(repo_root: Path, since: str) -> list[str]:
27
+ return sorted(set(_git_lines(repo_root, ["diff", "--name-only", f"{since}..HEAD"])))
28
+
29
+
30
+ def _git_range_commits(repo_root: Path, since: str) -> list[dict[str, str]]:
31
+ commits: list[dict[str, str]] = []
32
+ for line in _git_lines(repo_root, ["log", "--oneline", f"{since}..HEAD"]):
33
+ commit, _, subject = line.partition(" ")
34
+ commits.append({"commit": commit, "subject": subject})
35
+ return commits
36
+
37
+
38
+ def _section_lines(lines: list[str], heading: str) -> list[str]:
39
+ start_idx = None
40
+ target = heading.strip().lower()
41
+ for idx, line in enumerate(lines):
42
+ if line.startswith("# ") and line[2:].strip().lower() == target:
43
+ start_idx = idx + 1
44
+ break
45
+ if start_idx is None:
46
+ return []
47
+ out: list[str] = []
48
+ for idx in range(start_idx, len(lines)):
49
+ line = lines[idx]
50
+ if line.startswith("# "):
51
+ break
52
+ out.append(line)
53
+ return out
54
+
55
+
56
+ def _doc_status(path: Path) -> str:
57
+ for line in path.read_text(encoding="utf-8").splitlines():
58
+ stripped = line.strip()
59
+ if stripped.startswith("> Status:"):
60
+ return stripped.split(":", 1)[1].strip()
61
+ return "Unknown"
62
+
63
+
64
+ def _doc_title_from_path(path: Path) -> str:
65
+ try:
66
+ lines = path.read_text(encoding="utf-8").splitlines()
67
+ except OSError:
68
+ return path.stem
69
+ for line in lines:
70
+ if line.startswith("## "):
71
+ payload = line.removeprefix("## ").strip()
72
+ if " - " in payload:
73
+ return payload.split(" - ", 1)[1].strip()
74
+ return payload
75
+ return path.stem
76
+
77
+
78
+ def _validation_lines_from_task(path: Path) -> list[str]:
79
+ try:
80
+ lines = path.read_text(encoding="utf-8").splitlines()
81
+ except OSError:
82
+ return []
83
+ values: list[str] = []
84
+ for line in _section_lines(lines, "Validation"):
85
+ stripped = line.strip()
86
+ if not stripped.startswith("- "):
87
+ continue
88
+ value = stripped[2:].strip()
89
+ if value and not value.lower().startswith("run `") and not value.lower().startswith("run the "):
90
+ values.append(value)
91
+ return values
92
+
93
+
94
+ def build_handoff(repo_root: Path, since: str) -> dict[str, object]:
95
+ changed_paths = _git_range_changed_paths(repo_root, since)
96
+ commits = _git_range_commits(repo_root, since)
97
+ surface = build_changed_surface_summary(changed_paths)
98
+ logics_docs: list[dict[str, object]] = []
99
+ validations: list[str] = []
100
+ for rel_path in changed_paths:
101
+ if not rel_path.startswith("logics/") or not rel_path.endswith(".md"):
102
+ continue
103
+ path = repo_root / rel_path
104
+ kind = path.parent.name
105
+ entry = {
106
+ "path": rel_path,
107
+ "ref": path.stem,
108
+ "kind": kind,
109
+ "title": _doc_title_from_path(path),
110
+ "status": _doc_status(path) if path.is_file() else "Unknown",
111
+ }
112
+ logics_docs.append(entry)
113
+ if kind == "tasks":
114
+ validations.extend(_validation_lines_from_task(path))
115
+ next_actions = [
116
+ "Run lint/audit if not already included in validation evidence.",
117
+ "Review changed files before committing or handing off.",
118
+ ]
119
+ if any(path.startswith("logics_manager/") for path in changed_paths):
120
+ next_actions.append("Run `PYTHONPATH=\"$PWD\" pytest tests/python -q` for Python CLI changes.")
121
+ if any(path.startswith("clients/vscode/src/") or path.startswith("clients/shared-web/media/") for path in changed_paths):
122
+ next_actions.append("Run the TypeScript/vitest checks for extension changes.")
123
+ return {
124
+ "since": since,
125
+ "commit_count": len(commits),
126
+ "commits": commits,
127
+ "changed_paths": changed_paths,
128
+ "surface": surface,
129
+ "logics_docs": logics_docs,
130
+ "validations": sorted(set(validations)),
131
+ "next_actions": next_actions,
132
+ }
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+
6
+ def build_changed_surface_summary(changed_paths: list[str]) -> dict[str, object]:
7
+ category_counter: Counter[str] = Counter()
8
+ for path in changed_paths:
9
+ normalized = path.replace("\\", "/")
10
+ if normalized.startswith("clients/vscode/src/") or normalized.startswith("clients/shared-web/media/"):
11
+ category_counter["plugin"] += 1
12
+ elif normalized.startswith("logics_manager/"):
13
+ category_counter["python-runtime"] += 1
14
+ elif normalized.startswith("logics/"):
15
+ category_counter["workflow-docs"] += 1
16
+ elif normalized.startswith("tests/") or "/tests/" in normalized or normalized.startswith("tests/python/"):
17
+ category_counter["tests"] += 1
18
+ elif normalized.endswith(".md"):
19
+ category_counter["docs"] += 1
20
+ else:
21
+ category_counter["other"] += 1
22
+ primary = category_counter.most_common(1)[0][0] if category_counter else "clean"
23
+ summary = {
24
+ "clean": "No changed surface was detected.",
25
+ "plugin": "The plugin surface is the dominant change area.",
26
+ "python-runtime": "The native Python runtime is the dominant change area.",
27
+ "workflow-docs": "Workflow documentation is the dominant change area.",
28
+ "tests": "Tests are the dominant change area.",
29
+ "docs": "Markdown documentation is the dominant change area.",
30
+ "other": "Mixed repository changes are present.",
31
+ }.get(primary, "Mixed repository changes are present.")
32
+ return {
33
+ "summary": summary,
34
+ "primary_category": primary,
35
+ "counts": dict(sorted(category_counter.items())),
36
+ "changed_paths": changed_paths,
37
+ "review_recommended": primary not in {"clean", "docs"} and bool(changed_paths),
38
+ }
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  from importlib import metadata
5
6
  import subprocess
6
7
  import sys
8
+ import sysconfig
7
9
  from shutil import which
8
10
  from pathlib import Path
9
11
 
@@ -20,6 +22,7 @@ from .lint import lint_payload, render_lint
20
22
  from .sync import search_logics_docs_payload
21
23
  from .doctor import render_doctor
22
24
  from .termstyle import colorize_help
25
+ from .update_check import get_update_notice
23
26
 
24
27
 
25
28
  DEFAULT_SELF_UPDATE_PY_PACKAGE = "logics-manager"
@@ -37,6 +40,7 @@ ROOT_COMMANDS = (
37
40
  "product-consistency",
38
41
  "status",
39
42
  "lint",
43
+ "view",
40
44
  "config",
41
45
  "doctor",
42
46
  "mcp",
@@ -88,6 +92,7 @@ def _build_root_help() -> str:
88
92
  " product-consistency Check product brief lineage links.",
89
93
  " status Summarize open workflow docs and next actions.",
90
94
  " search Search workflow docs directly.",
95
+ " view Start a local read-only browser viewer for the Logics corpus.",
91
96
  "",
92
97
  "Validation:",
93
98
  " lint Check filenames, headings, indicators, and changed-doc hygiene.",
@@ -130,6 +135,60 @@ def get_cli_version() -> str:
130
135
  return "0.0.0"
131
136
 
132
137
 
138
+ def _is_running_inside_venv() -> bool:
139
+ return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
140
+
141
+
142
+ def _is_externally_managed_python() -> bool:
143
+ if _is_running_inside_venv():
144
+ return False
145
+ stdlib = sysconfig.get_path("stdlib")
146
+ return bool(stdlib and (Path(stdlib) / "EXTERNALLY-MANAGED").exists())
147
+
148
+
149
+ def _is_running_from_npm_package() -> bool:
150
+ package_json = Path(__file__).resolve().parents[1] / "package.json"
151
+ try:
152
+ payload = json.loads(package_json.read_text(encoding="utf-8"))
153
+ except (OSError, json.JSONDecodeError):
154
+ return False
155
+ return payload.get("name") == DEFAULT_SELF_UPDATE_PACKAGE
156
+
157
+
158
+ def _print_externally_managed_update_guidance(package_name: str) -> None:
159
+ print(
160
+ "\n".join(
161
+ [
162
+ "This Python installation is externally managed, so pip cannot safely update logics-manager in the system environment.",
163
+ "",
164
+ "Recommended fix:",
165
+ " sudo apt update",
166
+ " sudo apt install pipx python3-venv",
167
+ " pipx ensurepath",
168
+ f" pipx install --force {package_name}",
169
+ "",
170
+ "If you installed the npm package instead, run:",
171
+ f" npm install -g {DEFAULT_SELF_UPDATE_PACKAGE}@latest",
172
+ "",
173
+ "Advanced override, at your own risk:",
174
+ f" logics-manager self-update --manager pip --break-system-packages",
175
+ ]
176
+ )
177
+ )
178
+
179
+
180
+ def _is_json_mode(argv: list[str]) -> bool:
181
+ return "--json" in argv or any(argv[index] == "--format" and index + 1 < len(argv) and argv[index + 1] == "json" for index in range(len(argv)))
182
+
183
+
184
+ def _maybe_print_update_notice(command: str, argv: list[str]) -> None:
185
+ if command in {"self-update", "mcp", "view"} or _is_json_mode(argv) or not sys.stdout.isatty():
186
+ return
187
+ notice = get_update_notice(get_cli_version())
188
+ if notice:
189
+ print(notice, file=sys.stderr)
190
+
191
+
133
192
  def main(argv: list[str] | None = None) -> int:
134
193
  if argv is None:
135
194
  argv = sys.argv[1:]
@@ -147,6 +206,7 @@ def main(argv: list[str] | None = None) -> int:
147
206
  command = argv[0]
148
207
  if command not in ROOT_COMMANDS:
149
208
  raise SystemExit(f"Unsupported command: {command}")
209
+ _maybe_print_update_notice(command, argv)
150
210
 
151
211
  rest = argv[1:]
152
212
  if command == "config":
@@ -192,20 +252,29 @@ def main(argv: list[str] | None = None) -> int:
192
252
  parser.add_argument("--manager", choices=("auto", "pip", "npm"), default="auto")
193
253
  parser.add_argument("--package", default=DEFAULT_SELF_UPDATE_PACKAGE)
194
254
  parser.add_argument("--python-package", default=DEFAULT_SELF_UPDATE_PY_PACKAGE)
255
+ parser.add_argument("--break-system-packages", action="store_true")
195
256
  parser.add_argument("--dry-run", action="store_true")
196
257
  parsed = parser.parse_args(rest)
197
258
 
198
259
  manager = parsed.manager
199
260
  if manager == "auto":
200
- try:
201
- metadata.version(parsed.python_package)
202
- except metadata.PackageNotFoundError:
203
- manager = "npm" if which("npm") else "pip"
261
+ if _is_running_from_npm_package() and which("npm"):
262
+ manager = "npm"
204
263
  else:
205
- manager = "pip"
264
+ try:
265
+ metadata.version(parsed.python_package)
266
+ except metadata.PackageNotFoundError:
267
+ manager = "npm" if which("npm") else "pip"
268
+ else:
269
+ manager = "pip"
206
270
 
207
271
  if manager == "pip":
272
+ if _is_externally_managed_python() and not parsed.break_system_packages:
273
+ _print_externally_managed_update_guidance(parsed.python_package)
274
+ return 1
208
275
  command = [sys.executable, "-m", "pip", "install", "--upgrade", parsed.python_package]
276
+ if parsed.break_system_packages:
277
+ command.append("--break-system-packages")
209
278
  else:
210
279
  npm = which("npm")
211
280
  if not npm:
@@ -240,6 +309,10 @@ def main(argv: list[str] | None = None) -> int:
240
309
  from .mcp import main as mcp_main
241
310
 
242
311
  return mcp_main(rest)
312
+ if command == "view":
313
+ from .viewer import main as viewer_main
314
+
315
+ return viewer_main(rest)
243
316
  if command == "audit":
244
317
  audit_parser = build_audit_parser()
245
318
  parsed = audit_parser.parse_args(rest)