personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): promote scripts/doctor.py to sol doctor subcommand

Move the install diagnostic from a standalone script into a registered
sol subcommand. think/doctor.py now holds the canonical logic;
scripts/doctor.py is a stdlib-only shim that delegates to it so
make doctor and pre-install fragile-mode invocations keep working.

- think/doctor.py: stdlib-only diagnostic module, exposes main(argv).
- scripts/doctor.py: thin sys.path bootstrap → think.doctor.main.
- think/sol_cli.py: registers "doctor" in COMMANDS, adds an
"Installation" group between "Specialized tools" and "Help".
- tests/test_doctor.py: drop importlib.util fixture, import
think.doctor directly; add e2e subprocess test of sol doctor --json.
- docs/SOLCLI.md, INSTALL.md: document sol doctor and the
fragile-mode python3 scripts/doctor.py fallback.

Output, exit codes, JSON schema, default port (5015), check battery,
severities, and fix lines are byte-for-byte identical (modulo
argparse prog in --help). Verified: diff between sol doctor --json
and python3 scripts/doctor.py --json is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+965 -929
+2
INSTALL.md
··· 80 80 81 81 sets up the repo-local python environment and installs all dependencies for development. it does not add `sol` to your PATH or install any user/system services. 82 82 83 + once installed, run `sol doctor` to diagnose the install. before `make install` has run, or on a machine without `.venv`, run `python3 scripts/doctor.py` from the repo root for the same diagnostic. 84 + 83 85 for repo-local use after this step, run `.venv/bin/sol`. 84 86 85 87 ## start solstone
+1
docs/SOLCLI.md
··· 299 299 | Talent (AI agents) | `agents`, `cortex`, `talent`, `call`, `engage` | 300 300 | Convey (web UI) | `convey`, `restart-convey`, `maint` | 301 301 | Specialized | `config`, `streams`, `journal-stats`, `formatter`, `detect-created` | 302 + | Installation | `doctor` | 302 303 | Help | `help`, `chat` | 303 304 304 305 ### Call (`sol call <app> <cmd>`)
+7 -915
scripts/doctor.py
··· 1 1 #!/usr/bin/env python3 2 2 # SPDX-License-Identifier: AGPL-3.0-only 3 3 # Copyright (c) 2026 sol pbc 4 - 5 - """Pre-install diagnostics for solstone. 4 + """Stdlib-only bootstrap shim for `sol doctor`. 6 5 7 - Runs a fixed battery of blocker and advisory checks using only the Python 8 - standard library so a fresh clone can be diagnosed before `uv sync`. Exit code 9 - `0` means no blocker failed; exit code `1` means at least one blocker-severity 10 - check failed. 11 - 12 - Decision log: 13 - - uv floor: 0.7.12 — `uv.lock` revision=3 requires >= 0.7.12 per 14 - astral-sh/uv#15220. 15 - - disk threshold: 10 GiB — measured `.venv`=7.88 GiB + 16 - uv-cache first-install growth ~1 GiB + buffer. 17 - - Makefile UV-guard strategy: MAKECMDGOALS filter; prep verified the 18 - doctor-only matrix on GNU make. 19 - - Ramon triage docs are absent in this worktree; the battery follows the task 20 - spec directly. 6 + Used by `make doctor` and as a pre-install entry point on machines that 7 + do not yet have `.venv` populated. Delegates to `think.doctor.main`, 8 + which holds the canonical diagnostic logic. 21 9 """ 22 10 23 11 from __future__ import annotations 24 12 25 - import argparse 26 - import importlib 27 - import json 28 - import os 29 - import plistlib 30 - import re 31 - import shutil 32 - import subprocess 33 13 import sys 34 - from dataclasses import dataclass, replace 35 14 from pathlib import Path 36 - from typing import Callable, Literal, Sequence 37 15 38 16 ROOT = Path(__file__).resolve().parent.parent 39 - MIN_UV = (0, 7, 12) 40 - MIN_FREE_GIB = 10.0 41 - 42 - Severity = Literal["blocker", "advisory"] 43 - Status = Literal["ok", "fail", "warn", "skip"] 44 - Platform = Literal["linux", "darwin"] 45 - 46 - 47 - @dataclass(frozen=True) 48 - class Args: 49 - verbose: bool 50 - json: bool 51 - port: int 52 - 53 - 54 - @dataclass(frozen=True) 55 - class Check: 56 - name: str 57 - severity: Severity 58 - platforms: tuple[Platform, ...] 59 - 60 - 61 - @dataclass(frozen=True) 62 - class CheckResult: 63 - name: str 64 - severity: Severity 65 - status: Status 66 - detail: str 67 - fix: str | None 68 - platform: str | None = None 69 - 70 - 71 - @dataclass(frozen=True) 72 - class ProbeOutput: 73 - stdout: str 74 - stderr: str 75 - returncode: int 76 - 77 - 78 - def platform_tag() -> Platform: 79 - if sys.platform == "darwin": 80 - return "darwin" 81 - return "linux" 82 - 83 - 84 - def make_result( 85 - check: Check, 86 - status: Status, 87 - detail: str, 88 - fix: str | None = None, 89 - *, 90 - platform: str | None = None, 91 - ) -> CheckResult: 92 - return CheckResult( 93 - name=check.name, 94 - severity=check.severity, 95 - status=status, 96 - detail=detail, 97 - fix=fix, 98 - platform=platform, 99 - ) 100 - 101 - 102 - def truncate(text: str, limit: int) -> str: 103 - text = " ".join(text.split()) 104 - if len(text) <= limit: 105 - return text 106 - return text[: limit - 3] + "..." 107 - 108 - 109 - def version_text(version: tuple[int, int, int]) -> str: 110 - return ".".join(str(part) for part in version) 111 - 112 - 113 - def parse_version(text: str) -> tuple[int, int, int] | None: 114 - match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) 115 - if not match: 116 - return None 117 - return tuple(int(part) for part in match.groups()) 118 - 119 - 120 - def compare_versions(left: tuple[int, int, int], right: tuple[int, int, int]) -> int: 121 - if left < right: 122 - return -1 123 - if left > right: 124 - return 1 125 - return 0 126 - 127 - 128 - def unexpected_output_result( 129 - check: Check, 130 - output: str, 131 - *, 132 - fix: str | None = None, 133 - ) -> CheckResult: 134 - snippet = truncate(output or "<empty>", 80) 135 - return make_result( 136 - check, 137 - "fail", 138 - f"probe returned unexpected output: {snippet}", 139 - fix, 140 - ) 141 - 142 - 143 - def command_text(cmd: Sequence[str]) -> str: 144 - return " ".join(cmd) 145 - 146 - 147 - def run_probe( 148 - check: Check, 149 - cmd: Sequence[str], 150 - *, 151 - timeout: float, 152 - cwd: Path | None = None, 153 - env: dict[str, str] | None = None, 154 - ok_returncodes: tuple[int, ...] = (0,), 155 - allow_nonzero: bool = False, 156 - allow_empty_stdout: bool = False, 157 - fix: str | None = None, 158 - ) -> ProbeOutput | CheckResult: 159 - merged_env = os.environ.copy() 160 - if env: 161 - merged_env.update(env) 162 - try: 163 - completed = subprocess.run( 164 - list(cmd), 165 - capture_output=True, 166 - text=True, 167 - timeout=timeout, 168 - cwd=str(cwd) if cwd else None, 169 - env=merged_env, 170 - check=False, 171 - ) 172 - except FileNotFoundError: 173 - return make_result(check, "fail", f"probe command not found: {cmd[0]}", fix) 174 - except subprocess.TimeoutExpired: 175 - return make_result( 176 - check, 177 - "fail", 178 - f"probe timed out after {timeout:g}s: {command_text(cmd)}", 179 - fix, 180 - ) 181 - except OSError as exc: 182 - return make_result( 183 - check, 184 - "fail", 185 - f"probe failed: {type(exc).__name__}: {exc}", 186 - fix, 187 - ) 188 - 189 - if completed.returncode not in ok_returncodes and not allow_nonzero: 190 - detail = completed.stderr.strip() or completed.stdout.strip() or "<empty>" 191 - return make_result( 192 - check, 193 - "fail", 194 - f"probe exited {completed.returncode}: {truncate(detail, 80)}", 195 - fix, 196 - ) 197 - 198 - if not allow_empty_stdout and not completed.stdout.strip(): 199 - return unexpected_output_result( 200 - check, 201 - completed.stderr.strip() or completed.stdout.strip(), 202 - fix=fix, 203 - ) 204 - 205 - return ProbeOutput( 206 - stdout=completed.stdout, 207 - stderr=completed.stderr, 208 - returncode=completed.returncode, 209 - ) 210 - 211 - 212 - def python_version_check(args: Args) -> CheckResult: 213 - del args 214 - check = CHECK_MAP["python_version"] 215 - pyproject = ROOT / "pyproject.toml" 216 - try: 217 - text = pyproject.read_text(encoding="utf-8") 218 - except OSError as exc: 219 - return make_result( 220 - check, 221 - "fail", 222 - f"could not read {pyproject.name}: {type(exc).__name__}: {exc}", 223 - "install Python >=3.10, then retry", 224 - ) 225 - match = re.search(r'^requires-python\s*=\s*"([^"]+)"', text, re.MULTILINE) 226 - if not match: 227 - return make_result( 228 - check, 229 - "fail", 230 - "could not parse requires-python from pyproject.toml", 231 - "install Python >=3.10, then retry", 232 - ) 233 - spec = match.group(1) 234 - min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) 235 - if not min_match: 236 - return make_result( 237 - check, 238 - "fail", 239 - f"unsupported requires-python specifier: {spec}", 240 - "install Python >=3.10, then retry", 241 - ) 242 - minimum = ( 243 - int(min_match.group(1)), 244 - int(min_match.group(2)), 245 - int(min_match.group(3) or 0), 246 - ) 247 - current = sys.version_info[:3] 248 - if compare_versions(current, minimum) < 0: 249 - return make_result( 250 - check, 251 - "fail", 252 - f"python {version_text(current)} does not satisfy {spec}", 253 - "install Python >=3.10, then `rm -rf .venv .installed && make install`", 254 - ) 255 - return make_result( 256 - check, 257 - "ok", 258 - f"python {version_text(current)} satisfies {spec}", 259 - ) 260 - 261 - 262 - def uv_installed_check(args: Args) -> CheckResult: 263 - del args 264 - check = CHECK_MAP["uv_installed"] 265 - fix = "curl -LsSf https://astral.sh/uv/install.sh | sh" 266 - probe = run_probe(check, ["uv", "--version"], timeout=0.5, fix=fix) 267 - if isinstance(probe, CheckResult): 268 - return probe 269 - version = parse_version(probe.stdout) 270 - if version is None: 271 - return unexpected_output_result(check, probe.stdout, fix=fix) 272 - if compare_versions(version, MIN_UV) < 0: 273 - return make_result( 274 - check, 275 - "fail", 276 - f"uv {version_text(version)} is older than required {version_text(MIN_UV)}", 277 - fix, 278 - ) 279 - return make_result( 280 - check, 281 - "ok", 282 - f"uv {version_text(version)} >= {version_text(MIN_UV)}", 283 - ) 284 - 285 - 286 - def venv_consistent_check(args: Args) -> CheckResult: 287 - del args 288 - check = CHECK_MAP["venv_consistent"] 289 - python_bin = ROOT / ".venv" / "bin" / "python" 290 - expected = (ROOT / ".venv").resolve() 291 - if not python_bin.exists(): 292 - return make_result( 293 - check, 294 - "skip", 295 - ".venv absent; run make install", 296 - ) 297 - probe = run_probe( 298 - check, 299 - [str(python_bin), "-c", "import sys; print(sys.prefix)"], 300 - timeout=0.5, 301 - fix="rm -rf .venv .installed && make install", 302 - ) 303 - if isinstance(probe, CheckResult): 304 - return probe 305 - prefix_text = probe.stdout.strip() 306 - if not prefix_text: 307 - return unexpected_output_result( 308 - check, 309 - probe.stdout, 310 - fix="rm -rf .venv .installed && make install", 311 - ) 312 - actual = Path(prefix_text).resolve() 313 - if actual != expected: 314 - return make_result( 315 - check, 316 - "fail", 317 - f".venv points at {actual}, expected {expected}", 318 - "rm -rf .venv .installed && make install", 319 - ) 320 - return make_result(check, "ok", f".venv points at this repo ({expected})") 321 - 322 - 323 - def sol_importable_check(args: Args) -> CheckResult: 324 - del args 325 - check = CHECK_MAP["sol_importable"] 326 - python_bin = ROOT / ".venv" / "bin" / "python" 327 - fix = "rm -rf .venv .installed && make install" 328 - if not python_bin.exists(): 329 - return make_result(check, "skip", ".venv absent; run make install") 330 - probe = run_probe( 331 - check, 332 - [str(python_bin), "-c", "from think.sol_cli import main"], 333 - cwd=Path("/"), 334 - timeout=2.0, 335 - allow_nonzero=True, 336 - allow_empty_stdout=True, 337 - fix=fix, 338 - ) 339 - if isinstance(probe, CheckResult): 340 - return probe 341 - if probe.returncode == 0: 342 - return make_result( 343 - check, 344 - "ok", 345 - "from think.sol_cli import main succeeded outside repo cwd", 346 - ) 347 - stderr = probe.stderr.strip() 348 - if "ModuleNotFoundError: No module named 'think'" in stderr: 349 - return make_result( 350 - check, 351 - "fail", 352 - "ModuleNotFoundError: No module named 'think'", 353 - fix, 354 - ) 355 - first_line = next((line for line in stderr.splitlines() if line.strip()), "") 356 - detail = truncate( 357 - first_line 358 - or f"from think.sol_cli import main failed with exit {probe.returncode}", 359 - 120, 360 - ) 361 - return make_result(check, "fail", detail, fix) 362 - 363 - 364 - def npx_on_path_check(args: Args) -> CheckResult: 365 - del args 366 - check = CHECK_MAP["npx_on_path"] 367 - npx = shutil.which("npx") 368 - if npx is None: 369 - return make_result( 370 - check, 371 - "fail", 372 - "npx not found on PATH", 373 - "install Node/npm so `npx` is on PATH, then rerun doctor", 374 - ) 375 - return make_result(check, "ok", f"npx on PATH at {npx}") 376 - 377 - 378 - def npx_non_interactive_check(args: Args) -> CheckResult: 379 - del args 380 - check = CHECK_MAP["npx_non_interactive"] 381 - fix = "repair npm/npx, then rerun `CI=true npx --yes --version`" 382 - probe = run_probe( 383 - check, 384 - ["npx", "--yes", "--version"], 385 - timeout=2.0, 386 - env={"CI": "true"}, 387 - fix=fix, 388 - ) 389 - if isinstance(probe, CheckResult): 390 - return probe 391 - if not probe.stdout.strip(): 392 - return unexpected_output_result(check, probe.stdout, fix=fix) 393 - return make_result(check, "ok", "npx --yes is non-interactive") 394 - 395 - 396 - def resolve_alias_target() -> Path | None: 397 - alias = Path.home() / ".local" / "bin" / "sol" 398 - if not alias.exists() and not alias.is_symlink(): 399 - return None 400 - if alias.is_symlink(): 401 - target = Path(os.readlink(alias)) 402 - if not target.is_absolute(): 403 - target = alias.parent / target 404 - return target.resolve() 405 - 406 - try: 407 - from think.install_guard import parse_wrapper 408 - 409 - content = alias.read_text(encoding="utf-8") 410 - except OSError: 411 - return None 412 - parsed = parse_wrapper(content) 413 - if parsed is None: 414 - return None 415 - return Path(parsed["sol_bin"]).resolve() 416 - 417 - 418 - def resolve_darwin_exe( 419 - check: Check, 420 - pid: int, 421 - *, 422 - expected_repo_sol: Path, 423 - alias_target: Path | None, 424 - ) -> Path | CheckResult: 425 - probe = run_probe( 426 - check, 427 - ["lsof", "-p", str(pid), "-Fn"], 428 - timeout=1.0, 429 - allow_empty_stdout=False, 430 - fix=f"kill {pid} # or run 'sol service stop' if this is your install", 431 - ) 432 - if isinstance(probe, CheckResult): 433 - return probe 434 - paths: list[Path] = [] 435 - for line in probe.stdout.splitlines(): 436 - if not line.startswith("n"): 437 - continue 438 - value = line[1:].strip() 439 - if not value.startswith("/"): 440 - continue 441 - paths.append(Path(value).resolve()) 442 - if not paths: 443 - return make_result( 444 - check, 445 - "fail", 446 - f"could not verify ownership (pid={pid}): no executable path from lsof", 447 - f"kill {pid} # or run 'sol service stop' if this is your install", 448 - ) 449 - for candidate in paths: 450 - if candidate == expected_repo_sol or candidate == alias_target: 451 - return candidate 452 - sol_paths = [candidate for candidate in paths if candidate.name == "sol"] 453 - if len(sol_paths) == 1: 454 - return sol_paths[0] 455 - return make_result( 456 - check, 457 - "fail", 458 - f"could not verify ownership (pid={pid}): ambiguous executable paths", 459 - f"kill {pid} # or run 'sol service stop' if this is your install", 460 - ) 461 - 462 - 463 - def port_5015_free_check(args: Args) -> CheckResult: 464 - check = CHECK_MAP["port_5015_free"] 465 - # In a git worktree (hopper lode, personal worktree) the host's port state 466 - # is not this worktree's concern — the worktree will never run its own 467 - # service. Skip, matching the pattern in stale_alias_symlink_check. 468 - try: 469 - alias_state_cls, check_alias_fn = import_install_guard() 470 - except Exception: 471 - pass 472 - else: 473 - state, _ = check_alias_fn(ROOT) 474 - if state is alias_state_cls.WORKTREE: 475 - return make_result( 476 - check, 477 - "skip", 478 - "git worktree; run doctor from the primary clone", 479 - ) 480 - port = args.port 481 - if shutil.which("lsof") is None: 482 - return make_result( 483 - check, 484 - "skip", 485 - "lsof not available; cannot probe port ownership", 486 - ) 487 - probe = run_probe( 488 - check, 489 - ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-Fpn"], 490 - timeout=1.0, 491 - ok_returncodes=(0, 1), 492 - allow_empty_stdout=True, 493 - fix="kill <pid> # or run 'sol service stop' if this is your install", 494 - ) 495 - if isinstance(probe, CheckResult): 496 - return replace(probe, status="warn") 497 - pids = [ 498 - line[1:].strip() for line in probe.stdout.splitlines() if line.startswith("p") 499 - ] 500 - if not pids: 501 - return make_result(check, "ok", f"port {port} is free") 502 - pid_text = pids[0] 503 - try: 504 - pid = int(pid_text) 505 - except ValueError: 506 - result = unexpected_output_result( 507 - check, 508 - probe.stdout, 509 - fix="kill <pid> # or run 'sol service stop' if this is your install", 510 - ) 511 - return replace(result, status="warn") 512 - expected_repo_sol = (ROOT / ".venv" / "bin" / "sol").resolve() 513 - alias_target = resolve_alias_target() 514 - if platform_tag() == "darwin": 515 - resolved = resolve_darwin_exe( 516 - check, 517 - pid, 518 - expected_repo_sol=expected_repo_sol, 519 - alias_target=alias_target, 520 - ) 521 - if isinstance(resolved, CheckResult): 522 - return replace(resolved, status="warn") 523 - exe_path = resolved 524 - else: 525 - try: 526 - exe_path = Path(os.readlink(f"/proc/{pid}/exe")).resolve() 527 - except OSError as exc: 528 - return make_result( 529 - check, 530 - "warn", 531 - f"could not verify ownership (pid={pid}): {type(exc).__name__}: {exc}", 532 - f"kill {pid} # or run 'sol service stop' if this is your install", 533 - ) 534 - if exe_path == expected_repo_sol: 535 - return make_result( 536 - check, 537 - "ok", 538 - f"port {port} owned by this repo's solstone ({exe_path})", 539 - ) 540 - if alias_target is not None and exe_path == alias_target: 541 - return make_result( 542 - check, 543 - "ok", 544 - f"port {port} owned by installed solstone ({exe_path})", 545 - ) 546 - return make_result( 547 - check, 548 - "warn", 549 - f"port {port} is in use by pid {pid} ({exe_path}); solstone may already be installed and active on this system", 550 - f"kill {pid} # or run 'sol service stop' if this is your install", 551 - ) 552 - 553 - 554 - def disk_space_check(args: Args) -> CheckResult: 555 - del args 556 - check = CHECK_MAP["disk_space"] 557 - usage = shutil.disk_usage(ROOT) 558 - free_gib = usage.free / (1024**3) 559 - if free_gib < MIN_FREE_GIB: 560 - return make_result( 561 - check, 562 - "warn", 563 - f"only {free_gib:.1f} GiB free on the repo filesystem (<{MIN_FREE_GIB:.0f} GiB)", 564 - "free disk on the repo filesystem before `make install`", 565 - ) 566 - return make_result( 567 - check, 568 - "ok", 569 - f"{free_gib:.1f} GiB free (>= {MIN_FREE_GIB:.0f} GiB)", 570 - ) 17 + sys.path.insert(0, str(ROOT)) 571 18 572 - 573 - def config_dir_readable_check(args: Args) -> CheckResult: 574 - del args 575 - check = CHECK_MAP["config_dir_readable"] 576 - home = Path.home() 577 - if not home.exists(): 578 - return make_result( 579 - check, 580 - "fail", 581 - f"home directory does not exist: {home}", 582 - f"fix ownership/permissions of {home}", 583 - ) 584 - required_access = os.R_OK | os.W_OK | os.X_OK 585 - if not os.access(home, required_access): 586 - return make_result( 587 - check, 588 - "fail", 589 - f"home directory is not readable and writable: {home}", 590 - f"fix ownership/permissions of {home}", 591 - ) 592 - current_platform = platform_tag() 593 - if current_platform == "darwin": 594 - config_dir = home / "Library" / "LaunchAgents" 595 - else: 596 - config_dir = home / ".config" 597 - if config_dir.exists() and not os.access(config_dir, required_access): 598 - return make_result( 599 - check, 600 - "fail", 601 - f"service config directory is not writable: {config_dir}", 602 - f"fix ownership/permissions of {config_dir}", 603 - ) 604 - if config_dir.exists(): 605 - detail = f"home and service config dir are writable ({config_dir})" 606 - else: 607 - detail = f"home is writable; install will create {config_dir}" 608 - return make_result(check, "ok", detail) 609 - 610 - 611 - def import_install_guard() -> tuple[object, object]: 612 - root_text = str(ROOT) 613 - if root_text not in sys.path: 614 - sys.path.insert(0, root_text) 615 - module = importlib.import_module("think.install_guard") 616 - return module.AliasState, module.check_alias 617 - 618 - 619 - def stale_alias_symlink_check(args: Args) -> CheckResult: 620 - del args 621 - check = CHECK_MAP["stale_alias_symlink"] 622 - try: 623 - alias_state_cls, check_alias = import_install_guard() 624 - except Exception as exc: 625 - return make_result( 626 - check, 627 - "skip", 628 - f"could not import think.install_guard: {type(exc).__name__}: {exc}", 629 - ) 630 - state, other = check_alias(ROOT) 631 - worktree = alias_state_cls.WORKTREE 632 - absent = alias_state_cls.ABSENT 633 - owned = alias_state_cls.OWNED 634 - cross_repo = alias_state_cls.CROSS_REPO 635 - dangling = alias_state_cls.DANGLING 636 - foreign = alias_state_cls.FOREIGN 637 - if state is worktree: 638 - return make_result( 639 - check, 640 - "skip", 641 - "git worktree; run doctor from the primary clone", 642 - ) 643 - if state in {absent, owned}: 644 - return make_result( 645 - check, 646 - "ok", 647 - "sol alias absent or owned by this repo", 648 - ) 649 - if state is cross_repo: 650 - detail = f"~/.local/bin/sol points at another repo ({other})" 651 - elif state is dangling: 652 - detail = f"~/.local/bin/sol is dangling ({other})" 653 - elif state is foreign: 654 - detail = "~/.local/bin/sol exists but is not a symlink" 655 - else: 656 - detail = f"unexpected alias state: {state}" 657 - return make_result( 658 - check, 659 - "fail", 660 - detail, 661 - "run `make uninstall-service` from the installed repo, or remove `~/.local/bin/sol` manually if the repo is gone", 662 - ) 663 - 664 - 665 - def macos_firewall_localhost_check(args: Args) -> CheckResult: 666 - del args 667 - check = CHECK_MAP["macos_firewall_localhost"] 668 - if platform_tag() != "darwin": 669 - return make_result(check, "skip", "not supported on linux", platform="linux") 670 - tool = "/usr/libexec/ApplicationFirewall/socketfilterfw" 671 - if not Path(tool).exists(): 672 - return make_result(check, "skip", "socketfilterfw not available") 673 - global_probe = run_probe( 674 - check, 675 - [tool, "--getglobalstate"], 676 - timeout=1.0, 677 - fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 678 - ) 679 - if isinstance(global_probe, CheckResult): 680 - return global_probe 681 - global_text = global_probe.stdout.lower() 682 - if "disabled" in global_text or "state = 0" in global_text: 683 - return make_result( 684 - check, 685 - "ok", 686 - "firewall settings will not block localhost service access", 687 - ) 688 - if "enabled" not in global_text and "state = 1" not in global_text: 689 - return unexpected_output_result( 690 - check, 691 - global_probe.stdout, 692 - fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 693 - ) 694 - block_probe = run_probe( 695 - check, 696 - [tool, "--getblockall"], 697 - timeout=1.0, 698 - fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 699 - ) 700 - if isinstance(block_probe, CheckResult): 701 - return block_probe 702 - block_text = block_probe.stdout.lower() 703 - if "enabled" in block_text or "state = 1" in block_text: 704 - return make_result( 705 - check, 706 - "warn", 707 - "firewall enabled with block-all incoming; localhost service access may fail", 708 - "sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 709 - ) 710 - if "disabled" in block_text or "state = 0" in block_text: 711 - return make_result( 712 - check, 713 - "ok", 714 - "firewall enabled but block-all incoming is off", 715 - ) 716 - return unexpected_output_result( 717 - check, 718 - block_probe.stdout, 719 - fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 720 - ) 721 - 722 - 723 - def launchd_stale_plist_check(args: Args) -> CheckResult: 724 - del args 725 - check = CHECK_MAP["launchd_stale_plist"] 726 - if platform_tag() != "darwin": 727 - return make_result(check, "skip", "not supported on linux", platform="linux") 728 - plist_path = Path.home() / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" 729 - if not plist_path.exists(): 730 - return make_result(check, "skip", "launchd plist absent") 731 - try: 732 - with plist_path.open("rb") as handle: 733 - data = plistlib.load(handle) 734 - except Exception as exc: 735 - return make_result( 736 - check, 737 - "fail", 738 - f"could not parse plist: {type(exc).__name__}: {exc}", 739 - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 740 - ) 741 - program_arguments = data.get("ProgramArguments") 742 - if not isinstance(program_arguments, list) or not program_arguments: 743 - return make_result( 744 - check, 745 - "fail", 746 - "plist is missing ProgramArguments[0]", 747 - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 748 - ) 749 - executable = Path(str(program_arguments[0])) 750 - if not executable.exists(): 751 - return make_result( 752 - check, 753 - "fail", 754 - f"plist points to missing executable: {executable}", 755 - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 756 - ) 757 - return make_result(check, "ok", f"launchd plist target exists ({executable})") 758 - 759 - 760 - def screen_recording_permission_check(args: Args) -> CheckResult: 761 - del args 762 - check = CHECK_MAP["screen_recording_permission"] 763 - if platform_tag() != "darwin": 764 - return make_result(check, "skip", "not supported on linux", platform="linux") 765 - return make_result( 766 - check, 767 - "skip", 768 - "no adopted non-prompting probe for CLI-scoped macOS TCC state", 769 - "System Settings → Privacy & Security → Screen Recording / Screen & System Audio Recording", 770 - ) 771 - 772 - 773 - def microphone_permission_check(args: Args) -> CheckResult: 774 - del args 775 - check = CHECK_MAP["microphone_permission"] 776 - if platform_tag() != "darwin": 777 - return make_result(check, "skip", "not supported on linux", platform="linux") 778 - return make_result( 779 - check, 780 - "skip", 781 - "no adopted non-prompting probe for CLI-scoped macOS TCC state", 782 - "System Settings → Privacy & Security → Microphone", 783 - ) 784 - 785 - 786 - CHECKS: list[tuple[Check, Callable[[Args], CheckResult]]] = [ 787 - (Check("python_version", "blocker", ("linux", "darwin")), python_version_check), 788 - (Check("uv_installed", "blocker", ("linux", "darwin")), uv_installed_check), 789 - (Check("venv_consistent", "blocker", ("linux", "darwin")), venv_consistent_check), 790 - (Check("sol_importable", "blocker", ("linux", "darwin")), sol_importable_check), 791 - (Check("npx_on_path", "blocker", ("linux", "darwin")), npx_on_path_check), 792 - ( 793 - Check("npx_non_interactive", "advisory", ("linux", "darwin")), 794 - npx_non_interactive_check, 795 - ), 796 - (Check("port_5015_free", "advisory", ("linux", "darwin")), port_5015_free_check), 797 - (Check("disk_space", "advisory", ("linux", "darwin")), disk_space_check), 798 - ( 799 - Check("config_dir_readable", "blocker", ("linux", "darwin")), 800 - config_dir_readable_check, 801 - ), 802 - ( 803 - Check("stale_alias_symlink", "blocker", ("linux", "darwin")), 804 - stale_alias_symlink_check, 805 - ), 806 - ( 807 - Check("macos_firewall_localhost", "advisory", ("darwin",)), 808 - macos_firewall_localhost_check, 809 - ), 810 - ( 811 - Check("launchd_stale_plist", "advisory", ("darwin",)), 812 - launchd_stale_plist_check, 813 - ), 814 - ( 815 - Check("screen_recording_permission", "advisory", ("darwin",)), 816 - screen_recording_permission_check, 817 - ), 818 - ( 819 - Check("microphone_permission", "advisory", ("darwin",)), 820 - microphone_permission_check, 821 - ), 822 - ] 823 - 824 - CHECK_MAP = {check.name: check for check, _func in CHECKS} 825 - 826 - 827 - def parse_args(argv: Sequence[str] | None = None) -> Args: 828 - parser = argparse.ArgumentParser( 829 - prog="doctor", 830 - description="Run pre-install diagnostics for solstone.", 831 - ) 832 - parser.add_argument( 833 - "--verbose", action="store_true", help="print every check result" 834 - ) 835 - parser.add_argument("--json", action="store_true", help="emit JSON instead of text") 836 - parser.add_argument( 837 - "--port", type=int, default=5015, help="port to probe (default: 5015)" 838 - ) 839 - namespace = parser.parse_args(argv) 840 - return Args( 841 - verbose=namespace.verbose, 842 - json=namespace.json, 843 - port=namespace.port, 844 - ) 845 - 846 - 847 - def run_checks(args: Args) -> list[CheckResult]: 848 - current_platform = platform_tag() 849 - results: list[CheckResult] = [] 850 - for check, func in CHECKS: 851 - if current_platform not in check.platforms: 852 - results.append( 853 - make_result( 854 - check, 855 - "skip", 856 - f"not supported on {current_platform}", 857 - platform=current_platform, 858 - ) 859 - ) 860 - continue 861 - results.append(func(args)) 862 - return results 863 - 864 - 865 - def print_result_line(result: CheckResult) -> None: 866 - label = result.status.upper() 867 - print(f" {label} {result.name} — {result.detail}") 868 - if result.fix: 869 - print(f" → {result.fix}") 870 - 871 - 872 - def summary_counts(results: Sequence[CheckResult]) -> dict[str, int]: 873 - return { 874 - "total": len(results), 875 - "failed": sum(1 for result in results if result.status == "fail"), 876 - "warnings": sum(1 for result in results if result.status == "warn"), 877 - "skipped": sum(1 for result in results if result.status == "skip"), 878 - } 879 - 880 - 881 - def emit_text(results: Sequence[CheckResult], *, verbose: bool) -> None: 882 - if verbose: 883 - for result in results: 884 - print_result_line(result) 885 - else: 886 - for result in results: 887 - if result.status in {"fail", "warn"}: 888 - print_result_line(result) 889 - summary = summary_counts(results) 890 - print( 891 - "doctor: " 892 - f"{summary['total']} checks, " 893 - f"{summary['failed']} failed, " 894 - f"{summary['warnings']} warnings, " 895 - f"{summary['skipped']} skipped" 896 - ) 897 - 898 - 899 - def emit_json(results: Sequence[CheckResult]) -> None: 900 - payload = { 901 - "checks": [ 902 - { 903 - "name": result.name, 904 - "severity": result.severity, 905 - "status": result.status, 906 - "detail": result.detail, 907 - "fix": result.fix, 908 - } 909 - for result in results 910 - ], 911 - "summary": summary_counts(results), 912 - } 913 - print(json.dumps(payload)) 914 - 915 - 916 - def main(argv: Sequence[str] | None = None) -> int: 917 - args = parse_args(argv) 918 - results = run_checks(args) 919 - if args.json: 920 - emit_json(results) 921 - else: 922 - emit_text(results, verbose=args.verbose) 923 - blocker_failed = any( 924 - result.severity == "blocker" and result.status == "fail" for result in results 925 - ) 926 - return 1 if blocker_failed else 0 927 - 19 + from think.doctor import main # noqa: E402 928 20 929 21 if __name__ == "__main__": 930 - raise SystemExit(main()) 22 + raise SystemExit(main(sys.argv[1:]))
+25 -14
tests/test_doctor.py
··· 3 3 4 4 from __future__ import annotations 5 5 6 - import importlib.util 7 6 import json 8 7 import os 9 8 import plistlib ··· 11 10 import socket 12 11 import subprocess 13 12 import sys 14 - import uuid 15 13 from pathlib import Path 16 14 from types import SimpleNamespace 17 15 ··· 24 22 25 23 @pytest.fixture 26 24 def doctor(): 27 - path = ROOT / "scripts" / "doctor.py" 28 - name = f"doctor_test_{uuid.uuid4().hex}" 29 - spec = importlib.util.spec_from_file_location(name, path) 30 - assert spec is not None 31 - assert spec.loader is not None 32 - module = importlib.util.module_from_spec(spec) 33 - sys.modules[name] = module 34 - spec.loader.exec_module(module) 35 - try: 36 - yield module 37 - finally: 38 - sys.modules.pop(name, None) 25 + from think import doctor as doctor_module 26 + 27 + yield doctor_module 39 28 40 29 41 30 @pytest.fixture ··· 674 663 doctor.main([]) 675 664 output = capsys.readouterr().out.strip().splitlines() 676 665 assert output[-1] == "doctor: 3 checks, 1 failed, 1 warnings, 1 skipped" 666 + 667 + 668 + def test_sol_doctor_subprocess_json_shape(): 669 + """End-to-end: `sol doctor --json` via the venv entry point produces valid diagnostic JSON.""" 670 + repo_root = Path(__file__).resolve().parent.parent 671 + result = subprocess.run( 672 + [sys.executable, "-m", "think.sol_cli", "doctor", "--json"], 673 + capture_output=True, 674 + text=True, 675 + cwd=repo_root, 676 + timeout=60, 677 + ) 678 + # Exit code: 0 if all checks pass, 1 if any blocker fails. Either is valid 679 + # here; this test asserts CLI routing and payload shape, not machine health. 680 + assert result.returncode in ( 681 + 0, 682 + 1, 683 + ), f"unexpected exit code {result.returncode}: {result.stderr}" 684 + payload = json.loads(result.stdout) 685 + assert "checks" in payload and isinstance(payload["checks"], list) 686 + assert "summary" in payload and isinstance(payload["summary"], dict) 687 + assert len(payload["checks"]) >= 1 677 688 678 689 679 690 class TestMakefileIntegration:
+928
think/doctor.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Pre-install diagnostics for solstone. 5 + 6 + Runs a fixed battery of blocker and advisory checks using only the Python 7 + standard library so a fresh clone can be diagnosed before `uv sync`. Exit code 8 + `0` means no blocker failed; exit code `1` means at least one blocker-severity 9 + check failed. 10 + 11 + Decision log: 12 + - uv floor: 0.7.12 — `uv.lock` revision=3 requires >= 0.7.12 per 13 + astral-sh/uv#15220. 14 + - disk threshold: 10 GiB — measured `.venv`=7.88 GiB + 15 + uv-cache first-install growth ~1 GiB + buffer. 16 + - Makefile UV-guard strategy: MAKECMDGOALS filter; prep verified the 17 + doctor-only matrix on GNU make. 18 + - Ramon triage docs are absent in this worktree; the battery follows the task 19 + spec directly. 20 + """ 21 + 22 + from __future__ import annotations 23 + 24 + import argparse 25 + import importlib 26 + import json 27 + import os 28 + import plistlib 29 + import re 30 + import shutil 31 + import subprocess 32 + import sys 33 + from dataclasses import dataclass, replace 34 + from pathlib import Path 35 + from typing import Callable, Literal, Sequence 36 + 37 + ROOT = Path(__file__).resolve().parent.parent 38 + MIN_UV = (0, 7, 12) 39 + MIN_FREE_GIB = 10.0 40 + 41 + Severity = Literal["blocker", "advisory"] 42 + Status = Literal["ok", "fail", "warn", "skip"] 43 + Platform = Literal["linux", "darwin"] 44 + 45 + 46 + @dataclass(frozen=True) 47 + class Args: 48 + verbose: bool 49 + json: bool 50 + port: int 51 + 52 + 53 + @dataclass(frozen=True) 54 + class Check: 55 + name: str 56 + severity: Severity 57 + platforms: tuple[Platform, ...] 58 + 59 + 60 + @dataclass(frozen=True) 61 + class CheckResult: 62 + name: str 63 + severity: Severity 64 + status: Status 65 + detail: str 66 + fix: str | None 67 + platform: str | None = None 68 + 69 + 70 + @dataclass(frozen=True) 71 + class ProbeOutput: 72 + stdout: str 73 + stderr: str 74 + returncode: int 75 + 76 + 77 + def platform_tag() -> Platform: 78 + if sys.platform == "darwin": 79 + return "darwin" 80 + return "linux" 81 + 82 + 83 + def make_result( 84 + check: Check, 85 + status: Status, 86 + detail: str, 87 + fix: str | None = None, 88 + *, 89 + platform: str | None = None, 90 + ) -> CheckResult: 91 + return CheckResult( 92 + name=check.name, 93 + severity=check.severity, 94 + status=status, 95 + detail=detail, 96 + fix=fix, 97 + platform=platform, 98 + ) 99 + 100 + 101 + def truncate(text: str, limit: int) -> str: 102 + text = " ".join(text.split()) 103 + if len(text) <= limit: 104 + return text 105 + return text[: limit - 3] + "..." 106 + 107 + 108 + def version_text(version: tuple[int, int, int]) -> str: 109 + return ".".join(str(part) for part in version) 110 + 111 + 112 + def parse_version(text: str) -> tuple[int, int, int] | None: 113 + match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) 114 + if not match: 115 + return None 116 + return tuple(int(part) for part in match.groups()) 117 + 118 + 119 + def compare_versions(left: tuple[int, int, int], right: tuple[int, int, int]) -> int: 120 + if left < right: 121 + return -1 122 + if left > right: 123 + return 1 124 + return 0 125 + 126 + 127 + def unexpected_output_result( 128 + check: Check, 129 + output: str, 130 + *, 131 + fix: str | None = None, 132 + ) -> CheckResult: 133 + snippet = truncate(output or "<empty>", 80) 134 + return make_result( 135 + check, 136 + "fail", 137 + f"probe returned unexpected output: {snippet}", 138 + fix, 139 + ) 140 + 141 + 142 + def command_text(cmd: Sequence[str]) -> str: 143 + return " ".join(cmd) 144 + 145 + 146 + def run_probe( 147 + check: Check, 148 + cmd: Sequence[str], 149 + *, 150 + timeout: float, 151 + cwd: Path | None = None, 152 + env: dict[str, str] | None = None, 153 + ok_returncodes: tuple[int, ...] = (0,), 154 + allow_nonzero: bool = False, 155 + allow_empty_stdout: bool = False, 156 + fix: str | None = None, 157 + ) -> ProbeOutput | CheckResult: 158 + merged_env = os.environ.copy() 159 + if env: 160 + merged_env.update(env) 161 + try: 162 + completed = subprocess.run( 163 + list(cmd), 164 + capture_output=True, 165 + text=True, 166 + timeout=timeout, 167 + cwd=str(cwd) if cwd else None, 168 + env=merged_env, 169 + check=False, 170 + ) 171 + except FileNotFoundError: 172 + return make_result(check, "fail", f"probe command not found: {cmd[0]}", fix) 173 + except subprocess.TimeoutExpired: 174 + return make_result( 175 + check, 176 + "fail", 177 + f"probe timed out after {timeout:g}s: {command_text(cmd)}", 178 + fix, 179 + ) 180 + except OSError as exc: 181 + return make_result( 182 + check, 183 + "fail", 184 + f"probe failed: {type(exc).__name__}: {exc}", 185 + fix, 186 + ) 187 + 188 + if completed.returncode not in ok_returncodes and not allow_nonzero: 189 + detail = completed.stderr.strip() or completed.stdout.strip() or "<empty>" 190 + return make_result( 191 + check, 192 + "fail", 193 + f"probe exited {completed.returncode}: {truncate(detail, 80)}", 194 + fix, 195 + ) 196 + 197 + if not allow_empty_stdout and not completed.stdout.strip(): 198 + return unexpected_output_result( 199 + check, 200 + completed.stderr.strip() or completed.stdout.strip(), 201 + fix=fix, 202 + ) 203 + 204 + return ProbeOutput( 205 + stdout=completed.stdout, 206 + stderr=completed.stderr, 207 + returncode=completed.returncode, 208 + ) 209 + 210 + 211 + def python_version_check(args: Args) -> CheckResult: 212 + del args 213 + check = CHECK_MAP["python_version"] 214 + pyproject = ROOT / "pyproject.toml" 215 + try: 216 + text = pyproject.read_text(encoding="utf-8") 217 + except OSError as exc: 218 + return make_result( 219 + check, 220 + "fail", 221 + f"could not read {pyproject.name}: {type(exc).__name__}: {exc}", 222 + "install Python >=3.10, then retry", 223 + ) 224 + match = re.search(r'^requires-python\s*=\s*"([^"]+)"', text, re.MULTILINE) 225 + if not match: 226 + return make_result( 227 + check, 228 + "fail", 229 + "could not parse requires-python from pyproject.toml", 230 + "install Python >=3.10, then retry", 231 + ) 232 + spec = match.group(1) 233 + min_match = re.search(r">=\s*(\d+)\.(\d+)(?:\.(\d+))?", spec) 234 + if not min_match: 235 + return make_result( 236 + check, 237 + "fail", 238 + f"unsupported requires-python specifier: {spec}", 239 + "install Python >=3.10, then retry", 240 + ) 241 + minimum = ( 242 + int(min_match.group(1)), 243 + int(min_match.group(2)), 244 + int(min_match.group(3) or 0), 245 + ) 246 + current = sys.version_info[:3] 247 + if compare_versions(current, minimum) < 0: 248 + return make_result( 249 + check, 250 + "fail", 251 + f"python {version_text(current)} does not satisfy {spec}", 252 + "install Python >=3.10, then `rm -rf .venv .installed && make install`", 253 + ) 254 + return make_result( 255 + check, 256 + "ok", 257 + f"python {version_text(current)} satisfies {spec}", 258 + ) 259 + 260 + 261 + def uv_installed_check(args: Args) -> CheckResult: 262 + del args 263 + check = CHECK_MAP["uv_installed"] 264 + fix = "curl -LsSf https://astral.sh/uv/install.sh | sh" 265 + probe = run_probe(check, ["uv", "--version"], timeout=0.5, fix=fix) 266 + if isinstance(probe, CheckResult): 267 + return probe 268 + version = parse_version(probe.stdout) 269 + if version is None: 270 + return unexpected_output_result(check, probe.stdout, fix=fix) 271 + if compare_versions(version, MIN_UV) < 0: 272 + return make_result( 273 + check, 274 + "fail", 275 + f"uv {version_text(version)} is older than required {version_text(MIN_UV)}", 276 + fix, 277 + ) 278 + return make_result( 279 + check, 280 + "ok", 281 + f"uv {version_text(version)} >= {version_text(MIN_UV)}", 282 + ) 283 + 284 + 285 + def venv_consistent_check(args: Args) -> CheckResult: 286 + del args 287 + check = CHECK_MAP["venv_consistent"] 288 + python_bin = ROOT / ".venv" / "bin" / "python" 289 + expected = (ROOT / ".venv").resolve() 290 + if not python_bin.exists(): 291 + return make_result( 292 + check, 293 + "skip", 294 + ".venv absent; run make install", 295 + ) 296 + probe = run_probe( 297 + check, 298 + [str(python_bin), "-c", "import sys; print(sys.prefix)"], 299 + timeout=0.5, 300 + fix="rm -rf .venv .installed && make install", 301 + ) 302 + if isinstance(probe, CheckResult): 303 + return probe 304 + prefix_text = probe.stdout.strip() 305 + if not prefix_text: 306 + return unexpected_output_result( 307 + check, 308 + probe.stdout, 309 + fix="rm -rf .venv .installed && make install", 310 + ) 311 + actual = Path(prefix_text).resolve() 312 + if actual != expected: 313 + return make_result( 314 + check, 315 + "fail", 316 + f".venv points at {actual}, expected {expected}", 317 + "rm -rf .venv .installed && make install", 318 + ) 319 + return make_result(check, "ok", f".venv points at this repo ({expected})") 320 + 321 + 322 + def sol_importable_check(args: Args) -> CheckResult: 323 + del args 324 + check = CHECK_MAP["sol_importable"] 325 + python_bin = ROOT / ".venv" / "bin" / "python" 326 + fix = "rm -rf .venv .installed && make install" 327 + if not python_bin.exists(): 328 + return make_result(check, "skip", ".venv absent; run make install") 329 + probe = run_probe( 330 + check, 331 + [str(python_bin), "-c", "from think.sol_cli import main"], 332 + cwd=Path("/"), 333 + timeout=2.0, 334 + allow_nonzero=True, 335 + allow_empty_stdout=True, 336 + fix=fix, 337 + ) 338 + if isinstance(probe, CheckResult): 339 + return probe 340 + if probe.returncode == 0: 341 + return make_result( 342 + check, 343 + "ok", 344 + "from think.sol_cli import main succeeded outside repo cwd", 345 + ) 346 + stderr = probe.stderr.strip() 347 + if "ModuleNotFoundError: No module named 'think'" in stderr: 348 + return make_result( 349 + check, 350 + "fail", 351 + "ModuleNotFoundError: No module named 'think'", 352 + fix, 353 + ) 354 + first_line = next((line for line in stderr.splitlines() if line.strip()), "") 355 + detail = truncate( 356 + first_line 357 + or f"from think.sol_cli import main failed with exit {probe.returncode}", 358 + 120, 359 + ) 360 + return make_result(check, "fail", detail, fix) 361 + 362 + 363 + def npx_on_path_check(args: Args) -> CheckResult: 364 + del args 365 + check = CHECK_MAP["npx_on_path"] 366 + npx = shutil.which("npx") 367 + if npx is None: 368 + return make_result( 369 + check, 370 + "fail", 371 + "npx not found on PATH", 372 + "install Node/npm so `npx` is on PATH, then rerun doctor", 373 + ) 374 + return make_result(check, "ok", f"npx on PATH at {npx}") 375 + 376 + 377 + def npx_non_interactive_check(args: Args) -> CheckResult: 378 + del args 379 + check = CHECK_MAP["npx_non_interactive"] 380 + fix = "repair npm/npx, then rerun `CI=true npx --yes --version`" 381 + probe = run_probe( 382 + check, 383 + ["npx", "--yes", "--version"], 384 + timeout=2.0, 385 + env={"CI": "true"}, 386 + fix=fix, 387 + ) 388 + if isinstance(probe, CheckResult): 389 + return probe 390 + if not probe.stdout.strip(): 391 + return unexpected_output_result(check, probe.stdout, fix=fix) 392 + return make_result(check, "ok", "npx --yes is non-interactive") 393 + 394 + 395 + def resolve_alias_target() -> Path | None: 396 + alias = Path.home() / ".local" / "bin" / "sol" 397 + if not alias.exists() and not alias.is_symlink(): 398 + return None 399 + if alias.is_symlink(): 400 + target = Path(os.readlink(alias)) 401 + if not target.is_absolute(): 402 + target = alias.parent / target 403 + return target.resolve() 404 + 405 + try: 406 + from think.install_guard import parse_wrapper 407 + 408 + content = alias.read_text(encoding="utf-8") 409 + except OSError: 410 + return None 411 + parsed = parse_wrapper(content) 412 + if parsed is None: 413 + return None 414 + return Path(parsed["sol_bin"]).resolve() 415 + 416 + 417 + def resolve_darwin_exe( 418 + check: Check, 419 + pid: int, 420 + *, 421 + expected_repo_sol: Path, 422 + alias_target: Path | None, 423 + ) -> Path | CheckResult: 424 + probe = run_probe( 425 + check, 426 + ["lsof", "-p", str(pid), "-Fn"], 427 + timeout=1.0, 428 + allow_empty_stdout=False, 429 + fix=f"kill {pid} # or run 'sol service stop' if this is your install", 430 + ) 431 + if isinstance(probe, CheckResult): 432 + return probe 433 + paths: list[Path] = [] 434 + for line in probe.stdout.splitlines(): 435 + if not line.startswith("n"): 436 + continue 437 + value = line[1:].strip() 438 + if not value.startswith("/"): 439 + continue 440 + paths.append(Path(value).resolve()) 441 + if not paths: 442 + return make_result( 443 + check, 444 + "fail", 445 + f"could not verify ownership (pid={pid}): no executable path from lsof", 446 + f"kill {pid} # or run 'sol service stop' if this is your install", 447 + ) 448 + for candidate in paths: 449 + if candidate == expected_repo_sol or candidate == alias_target: 450 + return candidate 451 + sol_paths = [candidate for candidate in paths if candidate.name == "sol"] 452 + if len(sol_paths) == 1: 453 + return sol_paths[0] 454 + return make_result( 455 + check, 456 + "fail", 457 + f"could not verify ownership (pid={pid}): ambiguous executable paths", 458 + f"kill {pid} # or run 'sol service stop' if this is your install", 459 + ) 460 + 461 + 462 + def port_5015_free_check(args: Args) -> CheckResult: 463 + check = CHECK_MAP["port_5015_free"] 464 + # In a git worktree (hopper lode, personal worktree) the host's port state 465 + # is not this worktree's concern — the worktree will never run its own 466 + # service. Skip, matching the pattern in stale_alias_symlink_check. 467 + try: 468 + alias_state_cls, check_alias_fn = import_install_guard() 469 + except Exception: 470 + pass 471 + else: 472 + state, _ = check_alias_fn(ROOT) 473 + if state is alias_state_cls.WORKTREE: 474 + return make_result( 475 + check, 476 + "skip", 477 + "git worktree; run doctor from the primary clone", 478 + ) 479 + port = args.port 480 + if shutil.which("lsof") is None: 481 + return make_result( 482 + check, 483 + "skip", 484 + "lsof not available; cannot probe port ownership", 485 + ) 486 + probe = run_probe( 487 + check, 488 + ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-Fpn"], 489 + timeout=1.0, 490 + ok_returncodes=(0, 1), 491 + allow_empty_stdout=True, 492 + fix="kill <pid> # or run 'sol service stop' if this is your install", 493 + ) 494 + if isinstance(probe, CheckResult): 495 + return replace(probe, status="warn") 496 + pids = [ 497 + line[1:].strip() for line in probe.stdout.splitlines() if line.startswith("p") 498 + ] 499 + if not pids: 500 + return make_result(check, "ok", f"port {port} is free") 501 + pid_text = pids[0] 502 + try: 503 + pid = int(pid_text) 504 + except ValueError: 505 + result = unexpected_output_result( 506 + check, 507 + probe.stdout, 508 + fix="kill <pid> # or run 'sol service stop' if this is your install", 509 + ) 510 + return replace(result, status="warn") 511 + expected_repo_sol = (ROOT / ".venv" / "bin" / "sol").resolve() 512 + alias_target = resolve_alias_target() 513 + if platform_tag() == "darwin": 514 + resolved = resolve_darwin_exe( 515 + check, 516 + pid, 517 + expected_repo_sol=expected_repo_sol, 518 + alias_target=alias_target, 519 + ) 520 + if isinstance(resolved, CheckResult): 521 + return replace(resolved, status="warn") 522 + exe_path = resolved 523 + else: 524 + try: 525 + exe_path = Path(os.readlink(f"/proc/{pid}/exe")).resolve() 526 + except OSError as exc: 527 + return make_result( 528 + check, 529 + "warn", 530 + f"could not verify ownership (pid={pid}): {type(exc).__name__}: {exc}", 531 + f"kill {pid} # or run 'sol service stop' if this is your install", 532 + ) 533 + if exe_path == expected_repo_sol: 534 + return make_result( 535 + check, 536 + "ok", 537 + f"port {port} owned by this repo's solstone ({exe_path})", 538 + ) 539 + if alias_target is not None and exe_path == alias_target: 540 + return make_result( 541 + check, 542 + "ok", 543 + f"port {port} owned by installed solstone ({exe_path})", 544 + ) 545 + return make_result( 546 + check, 547 + "warn", 548 + f"port {port} is in use by pid {pid} ({exe_path}); solstone may already be installed and active on this system", 549 + f"kill {pid} # or run 'sol service stop' if this is your install", 550 + ) 551 + 552 + 553 + def disk_space_check(args: Args) -> CheckResult: 554 + del args 555 + check = CHECK_MAP["disk_space"] 556 + usage = shutil.disk_usage(ROOT) 557 + free_gib = usage.free / (1024**3) 558 + if free_gib < MIN_FREE_GIB: 559 + return make_result( 560 + check, 561 + "warn", 562 + f"only {free_gib:.1f} GiB free on the repo filesystem (<{MIN_FREE_GIB:.0f} GiB)", 563 + "free disk on the repo filesystem before `make install`", 564 + ) 565 + return make_result( 566 + check, 567 + "ok", 568 + f"{free_gib:.1f} GiB free (>= {MIN_FREE_GIB:.0f} GiB)", 569 + ) 570 + 571 + 572 + def config_dir_readable_check(args: Args) -> CheckResult: 573 + del args 574 + check = CHECK_MAP["config_dir_readable"] 575 + home = Path.home() 576 + if not home.exists(): 577 + return make_result( 578 + check, 579 + "fail", 580 + f"home directory does not exist: {home}", 581 + f"fix ownership/permissions of {home}", 582 + ) 583 + required_access = os.R_OK | os.W_OK | os.X_OK 584 + if not os.access(home, required_access): 585 + return make_result( 586 + check, 587 + "fail", 588 + f"home directory is not readable and writable: {home}", 589 + f"fix ownership/permissions of {home}", 590 + ) 591 + current_platform = platform_tag() 592 + if current_platform == "darwin": 593 + config_dir = home / "Library" / "LaunchAgents" 594 + else: 595 + config_dir = home / ".config" 596 + if config_dir.exists() and not os.access(config_dir, required_access): 597 + return make_result( 598 + check, 599 + "fail", 600 + f"service config directory is not writable: {config_dir}", 601 + f"fix ownership/permissions of {config_dir}", 602 + ) 603 + if config_dir.exists(): 604 + detail = f"home and service config dir are writable ({config_dir})" 605 + else: 606 + detail = f"home is writable; install will create {config_dir}" 607 + return make_result(check, "ok", detail) 608 + 609 + 610 + def import_install_guard() -> tuple[object, object]: 611 + root_text = str(ROOT) 612 + if root_text not in sys.path: 613 + sys.path.insert(0, root_text) 614 + module = importlib.import_module("think.install_guard") 615 + return module.AliasState, module.check_alias 616 + 617 + 618 + def stale_alias_symlink_check(args: Args) -> CheckResult: 619 + del args 620 + check = CHECK_MAP["stale_alias_symlink"] 621 + try: 622 + alias_state_cls, check_alias = import_install_guard() 623 + except Exception as exc: 624 + return make_result( 625 + check, 626 + "skip", 627 + f"could not import think.install_guard: {type(exc).__name__}: {exc}", 628 + ) 629 + state, other = check_alias(ROOT) 630 + worktree = alias_state_cls.WORKTREE 631 + absent = alias_state_cls.ABSENT 632 + owned = alias_state_cls.OWNED 633 + cross_repo = alias_state_cls.CROSS_REPO 634 + dangling = alias_state_cls.DANGLING 635 + foreign = alias_state_cls.FOREIGN 636 + if state is worktree: 637 + return make_result( 638 + check, 639 + "skip", 640 + "git worktree; run doctor from the primary clone", 641 + ) 642 + if state in {absent, owned}: 643 + return make_result( 644 + check, 645 + "ok", 646 + "sol alias absent or owned by this repo", 647 + ) 648 + if state is cross_repo: 649 + detail = f"~/.local/bin/sol points at another repo ({other})" 650 + elif state is dangling: 651 + detail = f"~/.local/bin/sol is dangling ({other})" 652 + elif state is foreign: 653 + detail = "~/.local/bin/sol exists but is not a symlink" 654 + else: 655 + detail = f"unexpected alias state: {state}" 656 + return make_result( 657 + check, 658 + "fail", 659 + detail, 660 + "run `make uninstall-service` from the installed repo, or remove `~/.local/bin/sol` manually if the repo is gone", 661 + ) 662 + 663 + 664 + def macos_firewall_localhost_check(args: Args) -> CheckResult: 665 + del args 666 + check = CHECK_MAP["macos_firewall_localhost"] 667 + if platform_tag() != "darwin": 668 + return make_result(check, "skip", "not supported on linux", platform="linux") 669 + tool = "/usr/libexec/ApplicationFirewall/socketfilterfw" 670 + if not Path(tool).exists(): 671 + return make_result(check, "skip", "socketfilterfw not available") 672 + global_probe = run_probe( 673 + check, 674 + [tool, "--getglobalstate"], 675 + timeout=1.0, 676 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 677 + ) 678 + if isinstance(global_probe, CheckResult): 679 + return global_probe 680 + global_text = global_probe.stdout.lower() 681 + if "disabled" in global_text or "state = 0" in global_text: 682 + return make_result( 683 + check, 684 + "ok", 685 + "firewall settings will not block localhost service access", 686 + ) 687 + if "enabled" not in global_text and "state = 1" not in global_text: 688 + return unexpected_output_result( 689 + check, 690 + global_probe.stdout, 691 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 692 + ) 693 + block_probe = run_probe( 694 + check, 695 + [tool, "--getblockall"], 696 + timeout=1.0, 697 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 698 + ) 699 + if isinstance(block_probe, CheckResult): 700 + return block_probe 701 + block_text = block_probe.stdout.lower() 702 + if "enabled" in block_text or "state = 1" in block_text: 703 + return make_result( 704 + check, 705 + "warn", 706 + "firewall enabled with block-all incoming; localhost service access may fail", 707 + "sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 708 + ) 709 + if "disabled" in block_text or "state = 0" in block_text: 710 + return make_result( 711 + check, 712 + "ok", 713 + "firewall enabled but block-all incoming is off", 714 + ) 715 + return unexpected_output_result( 716 + check, 717 + block_probe.stdout, 718 + fix="sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setblockall off", 719 + ) 720 + 721 + 722 + def launchd_stale_plist_check(args: Args) -> CheckResult: 723 + del args 724 + check = CHECK_MAP["launchd_stale_plist"] 725 + if platform_tag() != "darwin": 726 + return make_result(check, "skip", "not supported on linux", platform="linux") 727 + plist_path = Path.home() / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" 728 + if not plist_path.exists(): 729 + return make_result(check, "skip", "launchd plist absent") 730 + try: 731 + with plist_path.open("rb") as handle: 732 + data = plistlib.load(handle) 733 + except Exception as exc: 734 + return make_result( 735 + check, 736 + "fail", 737 + f"could not parse plist: {type(exc).__name__}: {exc}", 738 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 739 + ) 740 + program_arguments = data.get("ProgramArguments") 741 + if not isinstance(program_arguments, list) or not program_arguments: 742 + return make_result( 743 + check, 744 + "fail", 745 + "plist is missing ProgramArguments[0]", 746 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 747 + ) 748 + executable = Path(str(program_arguments[0])) 749 + if not executable.exists(): 750 + return make_result( 751 + check, 752 + "fail", 753 + f"plist points to missing executable: {executable}", 754 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 755 + ) 756 + return make_result(check, "ok", f"launchd plist target exists ({executable})") 757 + 758 + 759 + def screen_recording_permission_check(args: Args) -> CheckResult: 760 + del args 761 + check = CHECK_MAP["screen_recording_permission"] 762 + if platform_tag() != "darwin": 763 + return make_result(check, "skip", "not supported on linux", platform="linux") 764 + return make_result( 765 + check, 766 + "skip", 767 + "no adopted non-prompting probe for CLI-scoped macOS TCC state", 768 + "System Settings → Privacy & Security → Screen Recording / Screen & System Audio Recording", 769 + ) 770 + 771 + 772 + def microphone_permission_check(args: Args) -> CheckResult: 773 + del args 774 + check = CHECK_MAP["microphone_permission"] 775 + if platform_tag() != "darwin": 776 + return make_result(check, "skip", "not supported on linux", platform="linux") 777 + return make_result( 778 + check, 779 + "skip", 780 + "no adopted non-prompting probe for CLI-scoped macOS TCC state", 781 + "System Settings → Privacy & Security → Microphone", 782 + ) 783 + 784 + 785 + CHECKS: list[tuple[Check, Callable[[Args], CheckResult]]] = [ 786 + (Check("python_version", "blocker", ("linux", "darwin")), python_version_check), 787 + (Check("uv_installed", "blocker", ("linux", "darwin")), uv_installed_check), 788 + (Check("venv_consistent", "blocker", ("linux", "darwin")), venv_consistent_check), 789 + (Check("sol_importable", "blocker", ("linux", "darwin")), sol_importable_check), 790 + (Check("npx_on_path", "blocker", ("linux", "darwin")), npx_on_path_check), 791 + ( 792 + Check("npx_non_interactive", "advisory", ("linux", "darwin")), 793 + npx_non_interactive_check, 794 + ), 795 + (Check("port_5015_free", "advisory", ("linux", "darwin")), port_5015_free_check), 796 + (Check("disk_space", "advisory", ("linux", "darwin")), disk_space_check), 797 + ( 798 + Check("config_dir_readable", "blocker", ("linux", "darwin")), 799 + config_dir_readable_check, 800 + ), 801 + ( 802 + Check("stale_alias_symlink", "blocker", ("linux", "darwin")), 803 + stale_alias_symlink_check, 804 + ), 805 + ( 806 + Check("macos_firewall_localhost", "advisory", ("darwin",)), 807 + macos_firewall_localhost_check, 808 + ), 809 + ( 810 + Check("launchd_stale_plist", "advisory", ("darwin",)), 811 + launchd_stale_plist_check, 812 + ), 813 + ( 814 + Check("screen_recording_permission", "advisory", ("darwin",)), 815 + screen_recording_permission_check, 816 + ), 817 + ( 818 + Check("microphone_permission", "advisory", ("darwin",)), 819 + microphone_permission_check, 820 + ), 821 + ] 822 + 823 + CHECK_MAP = {check.name: check for check, _func in CHECKS} 824 + 825 + 826 + def parse_args(argv: Sequence[str] | None = None) -> Args: 827 + parser = argparse.ArgumentParser( 828 + description="Run pre-install diagnostics for solstone.", 829 + epilog=( 830 + "If 'sol doctor' is unavailable (e.g. before 'make install' completes), " 831 + "run 'python3 scripts/doctor.py' from the repo root for the same diagnostic." 832 + ), 833 + ) 834 + parser.add_argument( 835 + "--verbose", action="store_true", help="print every check result" 836 + ) 837 + parser.add_argument("--json", action="store_true", help="emit JSON instead of text") 838 + parser.add_argument( 839 + "--port", type=int, default=5015, help="port to probe (default: 5015)" 840 + ) 841 + namespace = parser.parse_args(argv) 842 + return Args( 843 + verbose=namespace.verbose, 844 + json=namespace.json, 845 + port=namespace.port, 846 + ) 847 + 848 + 849 + def run_checks(args: Args) -> list[CheckResult]: 850 + current_platform = platform_tag() 851 + results: list[CheckResult] = [] 852 + for check, func in CHECKS: 853 + if current_platform not in check.platforms: 854 + results.append( 855 + make_result( 856 + check, 857 + "skip", 858 + f"not supported on {current_platform}", 859 + platform=current_platform, 860 + ) 861 + ) 862 + continue 863 + results.append(func(args)) 864 + return results 865 + 866 + 867 + def print_result_line(result: CheckResult) -> None: 868 + label = result.status.upper() 869 + print(f" {label} {result.name} — {result.detail}") 870 + if result.fix: 871 + print(f" → {result.fix}") 872 + 873 + 874 + def summary_counts(results: Sequence[CheckResult]) -> dict[str, int]: 875 + return { 876 + "total": len(results), 877 + "failed": sum(1 for result in results if result.status == "fail"), 878 + "warnings": sum(1 for result in results if result.status == "warn"), 879 + "skipped": sum(1 for result in results if result.status == "skip"), 880 + } 881 + 882 + 883 + def emit_text(results: Sequence[CheckResult], *, verbose: bool) -> None: 884 + if verbose: 885 + for result in results: 886 + print_result_line(result) 887 + else: 888 + for result in results: 889 + if result.status in {"fail", "warn"}: 890 + print_result_line(result) 891 + summary = summary_counts(results) 892 + print( 893 + "doctor: " 894 + f"{summary['total']} checks, " 895 + f"{summary['failed']} failed, " 896 + f"{summary['warnings']} warnings, " 897 + f"{summary['skipped']} skipped" 898 + ) 899 + 900 + 901 + def emit_json(results: Sequence[CheckResult]) -> None: 902 + payload = { 903 + "checks": [ 904 + { 905 + "name": result.name, 906 + "severity": result.severity, 907 + "status": result.status, 908 + "detail": result.detail, 909 + "fix": result.fix, 910 + } 911 + for result in results 912 + ], 913 + "summary": summary_counts(results), 914 + } 915 + print(json.dumps(payload)) 916 + 917 + 918 + def main(argv: Sequence[str] | None = None) -> int: 919 + args = parse_args(argv) 920 + results = run_checks(args) 921 + if args.json: 922 + emit_json(results) 923 + else: 924 + emit_text(results, verbose=args.verbose) 925 + blocker_failed = any( 926 + result.severity == "blocker" and result.status == "fail" for result in results 927 + ) 928 + return 1 if blocker_failed else 0
+2
think/sol_cli.py
··· 46 46 "top": "think.top", 47 47 "health": "think.health_cli", 48 48 "notify": "think.notify_cli", 49 + "doctor": "think.doctor", 49 50 "config": "think.config_cli", 50 51 "password": "think.password_cli", 51 52 "streams": "think.streams", ··· 133 134 "journal-stats", 134 135 "link", 135 136 ], 137 + "Installation": ["doctor"], 136 138 "Help": ["chat"], 137 139 } 138 140