From 514b6f488678bf1dbd9b0409aff33279c0a54dc8 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 2 May 2026 16:17:26 -0600 Subject: [PATCH] feat(setup): add `sol setup` orchestrator for user-runtime install Replaces `make install-service` and `make uninstall-service` with a top-level `sol setup` command that orchestrates doctor, journal config, local model install, Claude Code skills install, wrapper install, and service install + start + health check. Works for both source-checkout and packaged installs (packaged installs skip wrapper and service in v1). The new command supports interactive, non-interactive (`--yes`), dry-run, and `--explain` modes. Each step is delegated to its existing sibling CLI; setup adds orchestration, manifest tracking at `/.setup-state.json`, dead-end recovery messages, and a final artifact summary. Re-runs are idempotent. Removed the `doctor`, `install-service`, and `uninstall-service` Make targets; `make install` no longer depends on `doctor` (which now lives inside `sol setup`). `make service-logs` is preserved. Updated all user-facing docs (`INSTALL.md`, `README.md`, `AGENTS.md`, `docs/INSTALL.md`, `docs/environment.md`), runtime error messages (`think/config_cli.py`, `think/doctor.py`, `think/install_guard.py`), and the `solstone` skill's install hint to point at `sol setup`. First-run on a fresh source checkout uses `.venv/bin/sol setup` until the wrapper installs. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 8 +- INSTALL.md | 10 +- Makefile | 99 +- README.md | 2 +- docs/INSTALL.md | 15 +- docs/environment.md | 2 +- observe/observer_install/linux.py | 4 +- observe/observer_install/tmux.py | 4 +- scripts/doctor.py | 6 +- skills/solstone/SKILL.md | 4 +- .../snapshots/linux_dry_run.txt | 2 +- .../snapshots/tmux_dry_run.txt | 2 +- tests/observer_install/test_linux.py | 23 +- tests/observer_install/test_tmux.py | 17 +- tests/test_config_cli.py | 4 +- tests/test_doctor.py | 38 +- tests/test_install_guard.py | 2 +- tests/test_setup.py | 411 ++++++++ think/config_cli.py | 4 +- think/doctor.py | 8 +- think/install_guard.py | 2 +- think/setup.py | 878 ++++++++++++++++++ think/sol_cli.py | 3 +- 23 files changed, 1363 insertions(+), 185 deletions(-) create mode 100644 tests/test_setup.py create mode 100644 think/setup.py diff --git a/AGENTS.md b/AGENTS.md index af30685b9..6cbec2e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,10 +134,10 @@ Verified against `Makefile`. Grouped by use. ### Service management (systemd / launchd) +`.venv/bin/sol setup` is the source-checkout runtime install path after `make install`; it installs or refreshes the source-checkout wrapper, installs the Claude Code skill when Claude is configured, and starts the background service on port 5015 by default. After the first run, the wrapper at `~/.local/bin/sol` lets you use `sol setup` from anywhere. Use `sol service ` for manual service operations. + | Target | When to use | |--------|-------------| -| `make install-service` | Install `sol` as a systemd user service (Linux) or launchd agent (macOS), convey on port 5015 (override with `PORT=8000`). Makes the machine a live solstone host — rarely wanted in a worktree. | -| `make uninstall-service` | Remove the installed service. | | `make service-logs` | Tail the installed service's logs. | ### Other @@ -151,7 +151,7 @@ Verified against `Makefile`. Grouped by use. | Target | Why not | |--------|---------| -| `make uninstall` | Disabled by design. Use `make uninstall-service` (for installed artifacts) or `make clean-install` (to rebuild the dev env). | +| `make uninstall` | Disabled by design. Use `sol service uninstall`, `sol skills uninstall`, and `python -m think.install_guard uninstall` for installed user artifacts, or `make clean-install` to rebuild the local dev env. | ## 6. Testing quickstart @@ -308,7 +308,7 @@ The live journal also carries `journal/AGENTS.md` as its runtime-facing breadcru - **Not a runtime guide for cogitate talents.** Runtime CLI restrictions on talents live in `talent/journal/references/cli.md` § Talent CLI Boundaries. If you're tuning what a talent can or cannot call, look there, not here. - **Not the journal-layout reference.** `talent/journal/SKILL.md` + its `references/` is the cogitate-audience entry point. This file describes *how those commands are implemented*, not *which ones talents can't call*. -- **Not an operations manual.** For debugging a live system see `docs/DOCTOR.md`; for service management, the `make install-service` family. +- **Not an operations manual.** For debugging a live system see `docs/DOCTOR.md`; for setup and service lifecycle, see `docs/INSTALL.md`, `sol setup`, and `sol service`. ## 13. Owner-facing copy: the system-anatomy canon diff --git a/INSTALL.md b/INSTALL.md index 4a12c90ef..a7f90f8d9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,7 +8,7 @@ the latest version of these instructions is at https://solstone.app/install ## before you begin -`make install-service` now auto-adds `~/.local/bin` to your shell `PATH` via the `userpath` library, updating `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish` as needed. if `~/.local/bin` was not already on `PATH`, restart your shell after install or run `exec $SHELL -l` before continuing. +`sol setup` adds `~/.local/bin` to your shell `PATH` when it installs the source-checkout wrapper via the `userpath` library, updating `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish` as needed. if `~/.local/bin` was not already on `PATH`, restart your shell after setup or run `exec $SHELL -l` before continuing. check if solstone is already installed and running: @@ -87,10 +87,10 @@ for repo-local use after this step, run `.venv/bin/sol`. ## start solstone ```bash -make install-service +.venv/bin/sol setup ``` -creates or refreshes the `~/.local/bin/sol` alias, installs the `solstone` skill for claude-code, and starts a background service (systemd on linux, launchd on macOS) with the web interface on port 5015. re-running it performs the upgrade path safely instead of conflicting with an existing install. +runs doctor, confirms the journal path, installs local models, installs the `solstone` skill for Claude Code when Claude is configured, creates or refreshes the `~/.local/bin/sol` wrapper for source-checkout installs, and starts a background service (systemd on linux, launchd on macOS) with the web interface on port 5015. use `.venv/bin/sol setup --port 8000` to choose another port on the first run. after the first run, the wrapper at `~/.local/bin/sol` lets you use just `sol` from anywhere. Service installation runs only on source-checkout installs in v1; packaged installs skip the service step. re-running it is safe. let your human know: **open http://localhost:5015 in a browser.** the first-run setup wizard walks them through choosing a password, setting their identity, and connecting a Gemini API key. once they've completed it, solstone is configured and ready. @@ -117,10 +117,10 @@ for manual build-from-source troubleshooting, use the per-observer repo docs: `s ## updating after a code change ```bash -git pull && make install-service +git pull && make install && .venv/bin/sol setup ``` -re-running `make install-service` handles both fresh installs and upgrades. on upgrade it runs fast install-time gates (`make install-checks` — formatting, lint, layer hygiene, mypy) first and aborts if anything fails, leaving the installed service untouched. the full test suite is no longer gated on install, because tests can flake under real service load. for a high-confidence upgrade, run `make verify && make install-service` to execute install-checks plus the test suite before touching the running service. +`make install` refreshes the repo-local Python environment. `.venv/bin/sol setup` then reruns the runtime setup gates and refreshes user-level artifacts. for a high-confidence upgrade, run `make verify && make install && .venv/bin/sol setup` before touching the running service. ## done diff --git a/Makefile b/Makefile index 6c8bb4685..067ab4ed6 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ export TMPDIR := /var/tmp PYTEST_BASETEMP_INIT := BASETEMP=$$(mktemp -d /var/tmp/solstone-pytest-XXXXXX); trap 'rm -rf "$$BASETEMP"' EXIT INT TERM; PYTEST_BASETEMP_FLAG := --basetemp "$$BASETEMP" -.PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format format-check install-checks ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sandbox sandbox-stop install-pinchtab install-models parakeet-helper parakeet-helper-clean verify-browser update-browser-baselines review verify verify-api update-api-baselines install-service uninstall-service service-logs check-layer-hygiene doctor FORCE +.PHONY: install uninstall test test-apps test-app test-only test-integration test-integration-only test-all format format-check install-checks ci clean clean-install coverage watch versions update update-prices pre-commit skills dev all sandbox sandbox-stop install-pinchtab install-models parakeet-helper parakeet-helper-clean verify-browser update-browser-baselines review verify verify-api update-api-baselines service-logs check-layer-hygiene FORCE # Default target - install package in editable mode all: install @@ -28,13 +28,9 @@ PARAKEET_ONNX_VARIANT ?= $(shell if nvidia-smi -L >/dev/null 2>&1; then echo cud # Require uv UV := $(shell command -v uv 2>/dev/null) -ifeq (,$(filter-out doctor,$(or $(MAKECMDGOALS),all))) -# doctor-only invocation — skip uv requirement so a uv-less machine can run diagnostics -else ifndef UV $(error uv is not installed. Install it: curl -LsSf https://astral.sh/uv/install.sh | sh) endif -endif # Node — add nvm bin dir to PATH if npx isn't already available NVM_BIN := $(lastword $(wildcard $(HOME)/.nvm/versions/node/*/bin)) @@ -75,7 +71,7 @@ uv.lock: pyproject.toml $(UV) lock # Install package in editable mode with isolated venv -install: doctor .installed +install: .installed @(cd /tmp && $(CURDIR)/$(VENV_BIN)/python -c "from think.sol_cli import main") 2>/dev/null || { \ echo ">>> re-registering editable install"; \ $(UV) pip install -e . --no-deps; \ @@ -413,101 +409,12 @@ clean: find . -type f -name ".DS_Store" -delete rm -f .installed -# Pre-install diagnostic — stdlib-only; runs on system python without uv/venv -doctor: - @python3 scripts/doctor.py $(if $(VERBOSE),--verbose) $(if $(JSON),--json) $(if $(PORT),--port $(PORT)) - -# Service management (override port: make install-service PORT=8000) -install-service: doctor .installed - @MODE=$$($(PYTHON) -m think.install_guard check); \ - RC=$$?; \ - case "$$MODE" in \ - worktree) \ - echo "mode: aborted — worktree"; \ - exit $$RC; \ - ;; \ - cross_repo) \ - echo "mode: aborted — cross_repo"; \ - exit $$RC; \ - ;; \ - dangling) \ - echo "mode: aborted — dangling"; \ - exit $$RC; \ - ;; \ - not_symlink) \ - echo "mode: aborted — not_symlink"; \ - exit $$RC; \ - ;; \ - up""grade) \ - echo "mode: up""grade"; \ - ;; \ - current) \ - echo "mode: current"; \ - ;; \ - fresh) \ - echo "mode: fresh install"; \ - ;; \ - *) \ - echo "mode: aborted — unknown"; \ - exit 2; \ - ;; \ - esac; \ - $(PYTHON) -m think.install_guard install; \ - $(VENV_BIN)/sol skills install; \ - $(VENV_BIN)/sol service install --port $(or $(PORT),5015); \ - $(VENV_BIN)/sol service restart; \ - echo "Waiting for supervisor to report healthy..."; \ - READY=false; \ - for i in $$(seq 1 20); do \ - if $(VENV_BIN)/sol health > /dev/null 2>&1; then \ - READY=true; \ - break; \ - fi; \ - printf .; \ - sleep 1; \ - done; \ - if [ "$$READY" = "true" ]; then \ - printf '\n'; \ - echo "Service is healthy."; \ - else \ - printf '\n' >&2; \ - echo "Service readiness timeout after 20s" >&2; \ - exit 1; \ - fi; \ - $(VENV_BIN)/sol service status - # Follow installed service logs service-logs: $(VENV_BIN)/sol service logs -f -uninstall-service: - @MODE=$$($(PYTHON) -m think.install_guard check); \ - RC=$$?; \ - HAS_SERVICE=false; \ - HAS_SKILL=false; \ - if [ -f "$$HOME/.config/systemd/user/solstone.service" ] || [ -f "$$HOME/Library/LaunchAgents/org.solpbc.solstone.plist" ]; then \ - HAS_SERVICE=true; \ - fi; \ - if [ -e "$$HOME/.claude/skills/solstone" ]; then \ - HAS_SKILL=true; \ - fi; \ - case "$$MODE" in \ - worktree|cross_repo|dangling|not_symlink) \ - echo "mode: aborted — $$MODE"; \ - exit $$RC; \ - ;; \ - esac; \ - if [ "$$MODE" = "fresh" ] && [ "$$HAS_SERVICE" = "false" ] && [ "$$HAS_SKILL" = "false" ]; then \ - echo "no artifacts to remove"; \ - exit 0; \ - fi; \ - $(VENV_BIN)/sol service stop > /dev/null 2>&1 || true; \ - $(VENV_BIN)/sol service uninstall; \ - $(VENV_BIN)/sol skills uninstall; \ - $(PYTHON) -m think.install_guard uninstall - uninstall: - @echo "Error: 'make uninstall' is disabled. Use the 'uninstall-service' target to remove installed user/system artifacts, or 'make clean-install' to rebuild the local dev environment." >&2 + @echo "Error: 'make uninstall' is disabled. Use 'sol service uninstall', 'sol skills uninstall', and 'python -m think.install_guard uninstall' to remove installed user artifacts, or 'make clean-install' to rebuild the local dev environment." >&2 @exit 1 FORCE: diff --git a/README.md b/README.md index 892d9fec4..5bd620010 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ make install # See docs/INSTALL.md for setup instructions # Install the CLI on PATH and start the background service (port 5015) -make install-service +.venv/bin/sol setup # Or start manually for development sol supervisor diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 84c439310..9a6a162fa 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -51,10 +51,13 @@ make install This creates an isolated virtual environment in `.venv/` for local development. Your system Python remains untouched, and no user-level CLI alias or service is installed yet. -To remove installed user/system artifacts: +To remove installed user/system artifacts later: ```bash -make uninstall-service +sol service stop +sol service uninstall +sol skills uninstall +python -m think.install_guard uninstall ``` To reset the repo-local development environment: @@ -130,16 +133,16 @@ For transcribing imported audio files. Sign up at [Rev.ai](https://www.rev.ai/), ### Install as a Background Service -The recommended way to run solstone is as a system service that starts automatically on login: +The recommended way to run solstone is through setup, which installs the runtime artifacts and starts the service: ```bash -make install-service +.venv/bin/sol setup ``` -This creates or refreshes the `~/.local/bin/sol` alias, installs the global `solstone` skill for claude-code, and installs, enables, and starts a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. Re-running it upgrades an existing install instead of conflicting. To use a custom port: +This creates or refreshes the `~/.local/bin/sol` wrapper for source-checkout installs, installs the global `solstone` skill for Claude Code when Claude is configured, and installs, enables, and starts a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. After the first run, the wrapper at `~/.local/bin/sol` lets you use just `sol` from anywhere. Service installation runs only on source-checkout installs in v1; packaged installs skip the service step. Re-running it is safe. To use a custom port on the first run: ```bash -make install-service PORT=8000 +.venv/bin/sol setup --port 8000 ``` Manage the service with: diff --git a/docs/environment.md b/docs/environment.md index e967c636d..d6721400b 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -30,7 +30,7 @@ If you think you need to set `SOLSTONE_JOURNAL` from application code, fix the a ## Service Installation -`make install-service` installs the managed wrapper at `~/.local/bin/sol`, then installs solstone as a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. Override with `make install-service PORT=8000`. +From a fresh source checkout, `.venv/bin/sol setup` installs the managed wrapper at `~/.local/bin/sol`, then installs solstone as a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. After the first run, the wrapper lets you use `sol setup` from anywhere. Override with `.venv/bin/sol setup --port 8000` on the first run or `sol setup --port 8000` after the wrapper exists. Service installation runs only on source-checkout installs in v1; packaged installs skip the service step. Installed services invoke `~/.local/bin/sol`. They do **not** write `SOLSTONE_JOURNAL` into the service env block; the wrapper exports it before execing the venv `sol`. diff --git a/observe/observer_install/linux.py b/observe/observer_install/linux.py index a13bdde6f..4bbeee980 100644 --- a/observe/observer_install/linux.py +++ b/observe/observer_install/linux.py @@ -225,7 +225,7 @@ class LinuxDriver: registration = create_or_reuse_registration(name, force=args.force) _write_config(server_url, registration.key, name) run_step( - "run make install-service", + "run observer install-service target", ["make", "install-service"], cwd=clone_dir, json_output=args.json_output, @@ -545,7 +545,7 @@ def _print_dry_run( print(f" would clone {SOURCE_URL} into {clone_dir}") print(f" would create observer registration '{name}'") print(f" would write {CONFIG_PATH}") - print(" would run: make install-service") + print(" would run observer install-service target") print(" would wait up to 30s for observer status") print(f" would write marker {marker_path(INSTALL_NAME)}") print() diff --git a/observe/observer_install/tmux.py b/observe/observer_install/tmux.py index a348048bb..a10459eb1 100644 --- a/observe/observer_install/tmux.py +++ b/observe/observer_install/tmux.py @@ -124,7 +124,7 @@ class TmuxDriver: registration = create_or_reuse_registration(name, force=args.force) _write_config(server_url, registration.key, name) run_step( - "run make install-service", + "run observer install-service target", ["make", "install-service"], cwd=clone_dir, json_output=args.json_output, @@ -372,7 +372,7 @@ def _print_dry_run( print(f" would clone {SOURCE_URL} into {clone_dir}") print(f" would create observer registration '{name}'") print(f" would write {CONFIG_PATH}") - print(" would run: make install-service") + print(" would run observer install-service target") print(" would wait up to 30s for observer status") print(f" would write marker {marker_path(INSTALL_NAME)}") print() diff --git a/scripts/doctor.py b/scripts/doctor.py index 3c4d9f15f..11ca2d8da 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -3,9 +3,9 @@ # Copyright (c) 2026 sol pbc """Stdlib-only bootstrap shim for `sol doctor`. -Used by `make doctor` and as a pre-install entry point on machines that -do not yet have `.venv` populated. Delegates to `think.doctor.main`, -which holds the canonical diagnostic logic. +Used as a pre-install entry point on machines that do not yet have `.venv` +populated. Delegates to `think.doctor.main`, which holds the canonical +diagnostic logic. """ from __future__ import annotations diff --git a/skills/solstone/SKILL.md b/skills/solstone/SKILL.md index a8f09494a..5ff07ea18 100644 --- a/skills/solstone/SKILL.md +++ b/skills/solstone/SKILL.md @@ -23,7 +23,7 @@ The `sol` CLI must be on PATH. Quick check: sol help ``` -If this fails, solstone is not installed. Install it from the solstone project: `make install-service`. +If this fails, solstone is not installed. Install it from the solstone project: `sol setup`. ## Capabilities @@ -179,7 +179,7 @@ If a task requires writing to the journal, it must be done from within the solst If `sol` is not found on PATH or returns an error: -- **"command not found: sol"** — solstone is not installed. The user needs to run `make install-service` in their solstone project. +- **"command not found: sol"** — solstone is not installed. The user needs to run `sol setup` in their solstone project. - **"journal not found"** or empty output — the journal directory doesn't exist or has no data yet. solstone may be installed but not yet initialized. - **Connection errors from `sol call support`** — `diagnose` is local-only and should always work. Other support commands (`search`, `article`) contact the support portal and may fail if offline. diff --git a/tests/observer_install/snapshots/linux_dry_run.txt b/tests/observer_install/snapshots/linux_dry_run.txt index cc0efee75..d9158542b 100644 --- a/tests/observer_install/snapshots/linux_dry_run.txt +++ b/tests/observer_install/snapshots/linux_dry_run.txt @@ -31,7 +31,7 @@ Plan: would clone https://github.com/solpbc/solstone-linux.git into /home/jer/.local/share/solstone/observers/solstone-linux would create observer registration 'archon' would write /home/jer/.local/share/solstone-linux/config/config.json - would run: make install-service + would run observer install-service target would wait up to 30s for observer status would write marker /home/jer/.local/share/solstone/observers/solstone-linux/.installed.json diff --git a/tests/observer_install/snapshots/tmux_dry_run.txt b/tests/observer_install/snapshots/tmux_dry_run.txt index 1cbfca7b2..192ae4916 100644 --- a/tests/observer_install/snapshots/tmux_dry_run.txt +++ b/tests/observer_install/snapshots/tmux_dry_run.txt @@ -21,7 +21,7 @@ Plan: would clone https://github.com/solpbc/solstone-tmux.git into /home/jer/.local/share/solstone/observers/solstone-tmux would create observer registration 'archon' would write /home/jer/.local/share/solstone-tmux/config/config.json - would run: make install-service + would run observer install-service target would wait up to 30s for observer status would write marker /home/jer/.local/share/solstone/observers/solstone-tmux/.installed.json diff --git a/tests/observer_install/test_linux.py b/tests/observer_install/test_linux.py index 4046e02c4..d16289cb4 100644 --- a/tests/observer_install/test_linux.py +++ b/tests/observer_install/test_linux.py @@ -87,10 +87,10 @@ def test_happy_path_writes_config_and_marker( return subprocess.CompletedProcess(cmd, 0, f"{linux.SOURCE_URL}\n", "") return subprocess.CompletedProcess(cmd, 0, "ok\n", "") - steps: list[str] = [] + steps: list[tuple[str, list[str]]] = [] def fake_step(label, cmd, **kwargs): - steps.append(label) + steps.append((label, cmd)) return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) monkeypatch.setattr(linux, "run_probe", fake_probe) @@ -98,7 +98,7 @@ def test_happy_path_writes_config_and_marker( assert linux.LinuxDriver().run(args_factory()) == 0 - assert "run make install-service" in steps + assert ("run observer install-service target", ["make", "install-service"]) in steps config = json.loads(linux.CONFIG_PATH.read_text(encoding="utf-8")) assert config["server_url"] == "http://127.0.0.1:5015" assert config["stream"] == "archon" @@ -151,10 +151,10 @@ def test_marker_present_no_upstream_changes_is_noop( return subprocess.CompletedProcess(cmd, 0, "active\n", "") return subprocess.CompletedProcess(cmd, 0, "ok\n", "") - steps: list[str] = [] + steps: list[tuple[str, list[str]]] = [] def fake_step(label, cmd, **kwargs): - steps.append(label) + steps.append((label, cmd)) return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) monkeypatch.setattr(linux, "run_probe", fake_probe) @@ -162,7 +162,7 @@ def test_marker_present_no_upstream_changes_is_noop( assert linux.LinuxDriver().run(args_factory()) == 0 - assert "run make install-service" not in steps + assert all(label != "run observer install-service target" for label, _cmd in steps) assert "already installed" in capsys.readouterr().out assert common.read_marker(linux.INSTALL_NAME)["last_run"] == "2026-05-02T00:00:00Z" @@ -185,10 +185,10 @@ def test_second_run_after_install_is_noop( return subprocess.CompletedProcess(cmd, 0, "active\n", "") return subprocess.CompletedProcess(cmd, 0, "ok\n", "") - steps: list[str] = [] + steps: list[tuple[str, list[str]]] = [] def fake_step(label, cmd, **kwargs): - steps.append(label) + steps.append((label, cmd)) if label.startswith("clone "): (common.xdg_install_dir(linux.INSTALL_NAME) / ".git").mkdir(parents=True) return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) @@ -216,7 +216,12 @@ def test_second_run_after_install_is_noop( assert linux.LinuxDriver().run(args_factory()) == 0 assert linux.LinuxDriver().run(args_factory()) == 0 - assert steps.count("run make install-service") == 1 + assert ( + steps.count( + ("run observer install-service target", ["make", "install-service"]) + ) + == 1 + ) assert config_writes == 1 assert marker_writes == 1 assert "already installed" in capsys.readouterr().out diff --git a/tests/observer_install/test_tmux.py b/tests/observer_install/test_tmux.py index e9c3b3ffb..025b04296 100644 --- a/tests/observer_install/test_tmux.py +++ b/tests/observer_install/test_tmux.py @@ -18,10 +18,10 @@ def test_tmux_happy_path_writes_config_and_marker(monkeypatch, args_factory): return subprocess.CompletedProcess(cmd, 0, "tmuxsha\n", "") return subprocess.CompletedProcess(cmd, 0, "ok\n", "") - steps: list[str] = [] + steps: list[tuple[str, list[str]]] = [] def fake_step(label, cmd, **kwargs): - steps.append(label) + steps.append((label, cmd)) return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) monkeypatch.setattr(tmux, "run_probe", fake_probe) @@ -29,7 +29,7 @@ def test_tmux_happy_path_writes_config_and_marker(monkeypatch, args_factory): assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 - assert "run make install-service" in steps + assert ("run observer install-service target", ["make", "install-service"]) in steps config = json.loads(tmux.CONFIG_PATH.read_text(encoding="utf-8")) assert config["stream"] == "archon" assert config["status_indicator"] is True @@ -93,10 +93,10 @@ def test_tmux_second_run_after_install_is_noop(monkeypatch, args_factory, capsys return subprocess.CompletedProcess(cmd, 0, "active\n", "") return subprocess.CompletedProcess(cmd, 0, "ok\n", "") - steps: list[str] = [] + steps: list[tuple[str, list[str]]] = [] def fake_step(label, cmd, **kwargs): - steps.append(label) + steps.append((label, cmd)) if label.startswith("clone "): (common.xdg_install_dir(tmux.INSTALL_NAME) / ".git").mkdir(parents=True) return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) @@ -124,7 +124,12 @@ def test_tmux_second_run_after_install_is_noop(monkeypatch, args_factory, capsys assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 - assert steps.count("run make install-service") == 1 + assert ( + steps.count( + ("run observer install-service target", ["make", "install-service"]) + ) + == 1 + ) assert config_writes == 1 assert marker_writes == 1 assert "already installed" in capsys.readouterr().out diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index 7608c4d62..7ed029212 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -241,7 +241,7 @@ def test_journal_refuses_without_managed_wrapper(home_root, tmp_path, capsys): assert rc == 1 assert captured.out == "" - assert "make install-service" in captured.err + assert "sol setup" in captured.err def test_journal_refuses_legacy_symlink(home_root, tmp_path, capsys): @@ -253,7 +253,7 @@ def test_journal_refuses_legacy_symlink(home_root, tmp_path, capsys): assert rc == 1 assert captured.out == "" - assert "make install-service" in captured.err + assert "sol setup" in captured.err def test_journal_refuses_invalid_chars(home_root, capsys): diff --git a/tests/test_doctor.py b/tests/test_doctor.py index e3201021e..67c3a2721 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -6,8 +6,6 @@ from __future__ import annotations import json import os import plistlib -import shutil -import socket import subprocess import sys from pathlib import Path @@ -688,7 +686,7 @@ def test_sol_doctor_subprocess_json_shape(): class TestMakefileIntegration: - def test_dry_run_orders_doctor_before_uv_sync(self): + def test_dry_run_install_does_not_run_doctor(self): result = subprocess.run( ["make", "--dry-run", "-B", "install"], cwd=ROOT, @@ -699,35 +697,5 @@ class TestMakefileIntegration: ) assert result.returncode == 0 lines = result.stdout.splitlines() - doctor_idx = next( - index - for index, line in enumerate(lines) - if "python3 scripts/doctor.py" in line - ) - uv_idx = next(index for index, line in enumerate(lines) if "uv sync" in line) - assert doctor_idx < uv_idx - - def test_install_service_aborts_before_running_when_doctor_fails(self, tmp_path): - if shutil.which("lsof") is None: - pytest.skip("lsof not available") - installed = ROOT / ".installed" - if not installed.exists(): - pytest.skip(".installed missing") - before = installed.stat().st_mtime - with socket.socket() as server: - server.bind(("127.0.0.1", 0)) - server.listen(1) - port = server.getsockname()[1] - env = os.environ.copy() - env["HOME"] = str(tmp_path / "home") - result = subprocess.run( - ["make", "install-service", f"PORT={port}"], - cwd=ROOT, - capture_output=True, - text=True, - check=False, - timeout=15, - env=env, - ) - assert result.returncode != 0 - assert installed.stat().st_mtime == before + assert all("python3 scripts/doctor.py" not in line for line in lines) + assert any("uv sync" in line for line in lines) diff --git a/tests/test_install_guard.py b/tests/test_install_guard.py index 9a431037e..f5f2d35eb 100644 --- a/tests/test_install_guard.py +++ b/tests/test_install_guard.py @@ -197,7 +197,7 @@ def alias_error(curdir: Path, installed: str, *, allow_force: bool = False) -> s "ERROR: Another solstone install owns ~/.local/bin/sol.\n" f" this repo: {curdir}\n" f"{installed}\n" - "Run 'make uninstall-service' from the installed repo first,\n" + "Run 'sol setup' from the installed repo first,\n" "or remove ~/.local/bin/sol manually if that repo is gone.\n" ) if allow_force: diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 000000000..a7085ab6b --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,411 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from think import health_cli, service, setup + + +def patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + return home + + +def patch_source_checkout(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").write_text("[project]\nname = 'solstone'\n") + (repo / ".git").mkdir() + monkeypatch.setattr(setup, "get_project_root", lambda: str(repo)) + return repo + + +def patch_packaged_install(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + root = tmp_path / "site-packages" + root.mkdir() + monkeypatch.setattr(setup, "get_project_root", lambda: str(root)) + return root + + +def patch_tty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + + +def doctor_payload(checks: list[dict[str, Any]] | None = None) -> str: + return json.dumps( + { + "checks": checks or [], + "summary": { + "total": len(checks or []), + "failed": 0, + "warnings": 0, + "skipped": 0, + }, + } + ) + + +def patch_subprocess( + monkeypatch: pytest.MonkeyPatch, + *, + doctor_stdout: str | None = None, + doctor_returncode: int = 0, + command_returncode: int = 0, +) -> list[list[str]]: + calls: list[list[str]] = [] + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + calls.append(command) + if "doctor" in command: + return subprocess.CompletedProcess( + command, + doctor_returncode, + stdout=doctor_stdout if doctor_stdout is not None else doctor_payload(), + stderr="doctor failed\n" if doctor_returncode else "", + ) + return subprocess.CompletedProcess(command, command_returncode) + + monkeypatch.setattr(setup.subprocess, "run", fake_run) + return calls + + +def patch_service_health(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(service, "_up", lambda port=5015: 0) + monkeypatch.setattr(health_cli, "health_check", lambda: 0) + + +def command_contains(calls: list[list[str]], *parts: str) -> bool: + return any(all(part in command for part in parts) for command in calls) + + +def read_manifest(journal: Path) -> dict[str, Any]: + return json.loads((journal / ".setup-state.json").read_text(encoding="utf-8")) + + +def test_interactive_happy_path_default_journal( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + patch_tty(monkeypatch) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + + rc = setup.main([]) + + assert rc == 0 + journal = home / "Documents" / "journal" + assert (home / ".config" / "solstone" / "config.toml").read_text( + encoding="utf-8" + ) == f'journal = "{journal}"\n' + manifest = read_manifest(journal) + assert [step["name"] for step in manifest["steps"]] == [ + "doctor", + "journal", + "install_models", + "skills", + "wrapper", + "service", + ] + assert "solstone is running at http://localhost:5015" in capsys.readouterr().out + assert command_contains(calls, "install-models") + assert command_contains(calls, "skills", "claude") + assert command_contains(calls, "think.install_guard", "install") + + +def test_interactive_happy_path_journal_override( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + patch_tty(monkeypatch) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "custom-journal" + + rc = setup.main(["--journal", str(journal)]) + + assert rc == 0 + assert (home / ".config" / "solstone" / "config.toml").read_text( + encoding="utf-8" + ) == f'journal = "{journal}"\n' + assert read_manifest(journal)["args_resolved"]["journal"]["source"] == "cli" + assert command_contains(calls, "think.install_guard", "install") + + +def test_non_interactive_happy_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 0 + manifest = read_manifest(journal) + assert manifest["completed_at"] is not None + assert len(manifest["steps"]) == 6 + assert command_contains(calls, "service", "install") + + +@pytest.mark.parametrize("use_journal_flag", [False, True]) +def test_non_interactive_dead_end_on_existing_journal( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + use_journal_flag: bool, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = ( + tmp_path / "journal" if use_journal_flag else home / "Documents" / "journal" + ) + (journal / "config").mkdir(parents=True) + argv = ["--yes"] + if use_journal_flag: + argv.extend(["--journal", str(journal)]) + + rc = setup.main(argv) + + assert rc == 2 + err = capsys.readouterr().err + assert "already contains journal data" in err + assert "--accept-existing-journal" in err + assert not command_contains(calls, "install-models") + assert not command_contains(calls, "skills") + assert not command_contains(calls, "think.install_guard") + assert not command_contains(calls, "service", "install") + + +def test_dry_run_side_effect_free( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + calls = patch_subprocess(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--dry-run", "--journal", str(journal)]) + + assert rc == 0 + assert calls == [] + assert not (home / ".config" / "solstone" / "config.toml").exists() + assert not (journal / ".setup-state.json").exists() + assert "setup dry-run:" in capsys.readouterr().out + + +def test_explain_early_exit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + calls = patch_subprocess(monkeypatch) + + rc = setup.main(["--explain"]) + + assert rc == 0 + assert calls == [] + assert "setup plan:" in capsys.readouterr().out + + +def test_manifest_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 0 + manifest = read_manifest(journal) + assert manifest["schema_version"] == 1 + assert manifest["mode"] == "non_interactive" + assert all( + Path(path).is_absolute() for step in manifest["steps"] for path in step["paths"] + ) + assert {step["status"] for step in manifest["steps"]} <= {"ok", "skipped", "failed"} + + +def test_idempotent_rerun_short_circuits( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + journal = tmp_path / "journal" + journal.mkdir() + (journal / ".setup-state.json").write_text( + json.dumps({"schema_version": 1, "completed_at": "2026-05-02T21:30:42Z"}), + encoding="utf-8", + ) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 0 + assert command_contains(calls, "doctor") + assert command_contains(calls, "install-models") + assert command_contains(calls, "think.install_guard") + assert read_manifest(journal)["completed_at"] is not None + + +def test_partial_completion_resumption( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + (home / ".claude").mkdir() + journal = tmp_path / "journal" + journal.mkdir() + (journal / ".setup-state.json").write_text( + json.dumps( + { + "schema_version": 1, + "steps": [ + {"name": "doctor", "status": "ok"}, + {"name": "service", "status": "failed"}, + ], + } + ), + encoding="utf-8", + ) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 0 + assert [step["name"] for step in read_manifest(journal)["steps"]] == [ + "doctor", + "journal", + "install_models", + "skills", + "wrapper", + "service", + ] + assert command_contains(calls, "service", "install") + + +def test_port_in_use_default_non_interactive_dead_end( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + calls = patch_subprocess( + monkeypatch, + doctor_stdout=doctor_payload( + [ + { + "name": "port_5015_free", + "severity": "advisory", + "status": "warn", + "detail": "port 5015 is in use by pid 123", + "fix": "kill 123", + } + ] + ), + ) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal)]) + + assert rc == 2 + assert "port 5015 is already in use" in capsys.readouterr().err + assert command_contains(calls, "doctor") + assert not command_contains(calls, "install-models") + + +def test_packaged_install_skips_service( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + patch_home(monkeypatch, tmp_path) + patch_packaged_install(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main( + ["--yes", "--journal", str(journal), "--skip-models", "--skip-skills"] + ) + + assert rc == 0 + out = capsys.readouterr().out + assert "packaged-install service support is not implemented in v1" in out + assert not command_contains(calls, "think.install_guard") + assert not command_contains(calls, "service", "install") + assert [step["status"] for step in read_manifest(journal)["steps"]][-2:] == [ + "skipped", + "skipped", + ] + + +def test_no_claude_config_skips_skills( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + patch_home(monkeypatch, tmp_path) + patch_source_checkout(monkeypatch, tmp_path) + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) + calls = patch_subprocess(monkeypatch) + patch_service_health(monkeypatch) + journal = tmp_path / "journal" + + rc = setup.main(["--yes", "--journal", str(journal), "--skip-models"]) + + assert rc == 0 + assert "Claude Code config not found" in capsys.readouterr().out + assert not command_contains(calls, "skills", "install") + skill_step = next( + step for step in read_manifest(journal)["steps"] if step["name"] == "skills" + ) + assert skill_step["status"] == "skipped" diff --git a/think/config_cli.py b/think/config_cli.py index 0de099329..69fd160ad 100644 --- a/think/config_cli.py +++ b/think/config_cli.py @@ -105,8 +105,8 @@ def _read_wrapper_status() -> tuple[str, str | None]: def _wrapper_refusal(alias: Path) -> str: return ( "sol config: refused: " - f"{alias} is not a managed wrapper (run 'make install-service' to " - "install the wrapper first)" + f"{alias} is not a managed wrapper (run 'sol setup' from the solstone " + "source checkout to install the wrapper first)" ) diff --git a/think/doctor.py b/think/doctor.py index ad14175a8..ce724f0e3 100644 --- a/think/doctor.py +++ b/think/doctor.py @@ -657,7 +657,7 @@ def stale_alias_symlink_check(args: Args) -> CheckResult: check, "fail", detail, - "run `make uninstall-service` from the installed repo, or remove `~/.local/bin/sol` manually if the repo is gone", + "run `sol setup` from the repo that owns the wrapper, or remove `~/.local/bin/sol` manually if the repo is gone", ) @@ -735,7 +735,7 @@ def launchd_stale_plist_check(args: Args) -> CheckResult: check, "fail", f"could not parse plist: {type(exc).__name__}: {exc}", - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", ) program_arguments = data.get("ProgramArguments") if not isinstance(program_arguments, list) or not program_arguments: @@ -743,7 +743,7 @@ def launchd_stale_plist_check(args: Args) -> CheckResult: check, "fail", "plist is missing ProgramArguments[0]", - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", ) executable = Path(str(program_arguments[0])) if not executable.exists(): @@ -751,7 +751,7 @@ def launchd_stale_plist_check(args: Args) -> CheckResult: check, "fail", f"plist points to missing executable: {executable}", - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", ) return make_result(check, "ok", f"launchd plist target exists ({executable})") diff --git a/think/install_guard.py b/think/install_guard.py index 9b0c0b6a7..7976bed15 100644 --- a/think/install_guard.py +++ b/think/install_guard.py @@ -226,7 +226,7 @@ def format_error( "ERROR: Another solstone install owns ~/.local/bin/sol.", f" this repo: {curdir}", installed, - "Run 'make uninstall-service' from the installed repo first,", + "Run 'sol setup' from the installed repo first,", "or remove ~/.local/bin/sol manually if that repo is gone.", ] if allow_force: diff --git a/think/setup.py b/think/setup.py new file mode 100644 index 000000000..8842badfb --- /dev/null +++ b/think/setup.py @@ -0,0 +1,878 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""User-runtime setup orchestration for solstone.""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import subprocess +import sys +import tempfile +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Literal + +from think.user_config import ( + config_path, + default_journal, + read_user_config, + write_user_config, +) +from think.utils import get_project_root + +TOTAL_STEPS = 6 +MANIFEST_SCHEMA_VERSION = 1 +HEALTH_ATTEMPTS = 20 +HEALTH_SLEEP_SECONDS = 1.0 + +StepStatus = Literal["ok", "skipped", "failed"] + + +class SetupMode(Enum): + INTERACTIVE = "interactive" + NON_INTERACTIVE = "non_interactive" + DRY_RUN = "dry_run" + EXPLAIN = "explain" + + +@dataclass +class SetupContext: + mode: SetupMode + project_root: Path + is_source_checkout: bool + journal_path: Path + journal_source: str + config_path: Path + manifest_path: Path + port: int + port_source: str + port_supplied: bool + variant: str + variant_source: str + yes: bool + skip_models: bool + skip_skills: bool + skip_service: bool + accept_existing_journal: bool + stdin_is_tty: bool + stdout_is_tty: bool + args_resolved: dict[str, object] + doctor_advisories: list[dict[str, Any]] + service_skipped_packaged: bool = False + + +@dataclass(frozen=True) +class StepResult: + name: str + status: StepStatus + paths: list[str] + started_at: str + finished_at: str + error: dict[str, object] | None + + +class SetupDeadEnd(Exception): + def __init__(self, message: str, exit_code: int = 2) -> None: + super().__init__(message) + self.message = message + self.exit_code = exit_code + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="sol setup", + description="Set up solstone user-runtime artifacts and start the service.", + ) + parser.add_argument( + "--journal", + metavar="PATH", + type=Path, + default=None, + help="journal directory to persist in ~/.config/solstone/config.toml", + ) + parser.add_argument( + "--port", + metavar="INT", + type=int, + default=5015, + help="convey service port (default: 5015)", + ) + parser.add_argument( + "--variant", + choices=("auto", "cpu", "cuda", "coreml"), + default="auto", + help="Parakeet model/runtime variant passed to sol install-models (default: auto)", + ) + parser.add_argument( + "-y", + "--yes", + "--non-interactive", + dest="yes", + action="store_true", + help="run without prompts; fail with retry guidance when input is required", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="print the resolved plan and commands without changing files or services", + ) + parser.add_argument( + "--explain", + action="store_true", + help="print the setup steps and resolved defaults without running them", + ) + parser.add_argument( + "--skip-models", + action="store_true", + help="skip local model installation", + ) + parser.add_argument( + "--skip-skills", + action="store_true", + help="skip Claude Code skill installation", + ) + parser.add_argument( + "--skip-service", + action="store_true", + help="skip service installation, start, and health check", + ) + parser.add_argument( + "--accept-existing-journal", + action="store_true", + help="allow setup to use a non-empty existing journal directory", + ) + return parser + + +def resolve_mode(args: argparse.Namespace) -> SetupMode: + stdin_is_tty = sys.stdin.isatty() + stdout_is_tty = sys.stdout.isatty() + + if args.explain: + return SetupMode.EXPLAIN + if args.dry_run: + return SetupMode.DRY_RUN + if args.yes: + return SetupMode.NON_INTERACTIVE + if stdin_is_tty and stdout_is_tty: + return SetupMode.INTERACTIVE + return SetupMode.NON_INTERACTIVE + + +def resolve_context(args: argparse.Namespace, raw_argv: list[str]) -> SetupContext: + mode = resolve_mode(args) + project_root = Path(get_project_root()) + is_source_checkout = (project_root / "pyproject.toml").exists() and ( + project_root / ".git" + ).exists() + journal_path, journal_source = resolve_journal_path(args) + cfg_path = config_path() + manifest_path = journal_path / ".setup-state.json" + port_supplied = arg_supplied(raw_argv, "--port") + variant_supplied = arg_supplied(raw_argv, "--variant") + + args_resolved: dict[str, object] = { + "journal": { + "value": str(journal_path), + "source": journal_source, + }, + "port": { + "value": args.port, + "source": "cli" if port_supplied else "default", + }, + "variant": { + "value": args.variant, + "source": "cli" if variant_supplied else "default", + }, + "yes": {"value": bool(args.yes), "source": "cli" if args.yes else "default"}, + "dry_run": { + "value": bool(args.dry_run), + "source": "cli" if args.dry_run else "default", + }, + "explain": { + "value": bool(args.explain), + "source": "cli" if args.explain else "default", + }, + "skip_models": { + "value": bool(args.skip_models), + "source": "cli" if args.skip_models else "default", + }, + "skip_skills": { + "value": bool(args.skip_skills), + "source": "cli" if args.skip_skills else "default", + }, + "skip_service": { + "value": bool(args.skip_service), + "source": "cli" if args.skip_service else "default", + }, + "accept_existing_journal": { + "value": bool(args.accept_existing_journal), + "source": "cli" if args.accept_existing_journal else "default", + }, + "parakeet_onnx_variant_env": { + "value": os.environ.get("PARAKEET_ONNX_VARIANT"), + "source": "env", + }, + "is_source_checkout": { + "value": is_source_checkout, + "source": "detected", + }, + } + + return SetupContext( + mode=mode, + project_root=project_root, + is_source_checkout=is_source_checkout, + journal_path=journal_path, + journal_source=journal_source, + config_path=cfg_path, + manifest_path=manifest_path, + port=args.port, + port_source="cli" if port_supplied else "default", + port_supplied=port_supplied, + variant=args.variant, + variant_source="cli" if variant_supplied else "default", + yes=bool(args.yes), + skip_models=bool(args.skip_models), + skip_skills=bool(args.skip_skills), + skip_service=bool(args.skip_service), + accept_existing_journal=bool(args.accept_existing_journal), + stdin_is_tty=sys.stdin.isatty(), + stdout_is_tty=sys.stdout.isatty(), + args_resolved=args_resolved, + doctor_advisories=[], + ) + + +def resolve_journal_path(args: argparse.Namespace) -> tuple[Path, str]: + if args.journal is not None: + return expand_path(args.journal), "cli" + + configured = read_user_config().get("journal", "").strip() + if configured: + return expand_path(configured), "config" + + return expand_path(default_journal()), "default" + + +def arg_supplied(raw_argv: list[str], flag: str) -> bool: + return flag in raw_argv or any(item.startswith(f"{flag}=") for item in raw_argv) + + +def expand_path(path: str | Path) -> Path: + return Path(path).expanduser().resolve() + + +def utc_now() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def absolute_string(path: Path) -> str: + return str(path.expanduser().resolve()) + + +def non_empty_journal(path: Path) -> bool: + return path.is_dir() and ( + (path / "config").is_dir() + or any(path.glob("*.jsonl")) + or any( + p.is_dir() and p.name.isdigit() and len(p.name) == 8 for p in path.iterdir() + ) + ) + + +def read_manifest(ctx: SetupContext) -> dict[str, Any] | None: + try: + return json.loads(ctx.manifest_path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def write_manifest(ctx: SetupContext, manifest: dict[str, Any]) -> None: + try: + ctx.manifest_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=".tmp_setup_state", + suffix=".json", + dir=ctx.manifest_path.parent, + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2) + handle.write("\n") + os.replace(tmp_path, ctx.manifest_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + except Exception as exc: + logging.warning("could not write setup manifest: %s", exc) + + +def initial_manifest(ctx: SetupContext) -> dict[str, Any]: + previous = read_manifest(ctx) + if previous is not None: + logging.debug("previous setup manifest found at %s", ctx.manifest_path) + return { + "schema_version": MANIFEST_SCHEMA_VERSION, + "started_at": utc_now(), + "completed_at": None, + "mode": ctx.mode.value, + "args_resolved": ctx.args_resolved, + "steps": [], + } + + +def append_step(manifest: dict[str, Any], result: StepResult) -> None: + steps = manifest.setdefault("steps", []) + steps.append(asdict(result)) + + +def step_result( + name: str, + status: StepStatus, + paths: list[Path | str], + started_at: str, + error: dict[str, object] | None = None, +) -> StepResult: + return StepResult( + name=name, + status=status, + paths=[absolute_string(Path(path)) for path in paths], + started_at=started_at, + finished_at=utc_now(), + error=error, + ) + + +def print_step_header( + step_index: int, label: str, command: list[str] | None = None +) -> None: + if command: + print( + f"[step {step_index}/{TOTAL_STEPS}] running {label}: {format_command(command)}" + ) + else: + print(f"[step {step_index}/{TOTAL_STEPS}] running {label}...") + + +def print_step_skipped(step_index: int, name: str, reason: str) -> None: + print(f"[step {step_index}/{TOTAL_STEPS}] skipped {name}: {reason}") + + +def format_command(command: list[str]) -> str: + return " ".join(command) + + +def run_inherited(command: list[str]) -> int: + result = subprocess.run(command, stdout=None, stderr=None, check=False) + return int(result.returncode) + + +def doctor_command(ctx: SetupContext) -> list[str]: + return [ + sys.executable, + "-m", + "think.sol_cli", + "doctor", + "--json", + "--port", + str(ctx.port), + ] + + +def install_models_command(ctx: SetupContext) -> list[str]: + return [ + sys.executable, + "-m", + "think.sol_cli", + "install-models", + "--variant", + ctx.variant, + ] + + +def skills_command() -> list[str]: + return [ + sys.executable, + "-m", + "think.sol_cli", + "skills", + "install", + "--agent", + "claude", + ] + + +def wrapper_command() -> list[str]: + return [sys.executable, "-m", "think.install_guard", "install"] + + +def service_install_command(ctx: SetupContext) -> list[str]: + return [ + sys.executable, + "-m", + "think.sol_cli", + "service", + "install", + "--port", + str(ctx.port), + ] + + +def step_doctor(ctx: SetupContext, step_index: int) -> StepResult: + started_at = utc_now() + command = doctor_command(ctx) + print_step_header(step_index, "doctor", command) + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + if result.stdout: + print(result.stdout, end="" if result.stdout.endswith("\n") else "\n") + if result.stderr: + print( + result.stderr, + end="" if result.stderr.endswith("\n") else "\n", + file=sys.stderr, + ) + return step_result( + "doctor", + "failed", + [], + started_at, + {"message": "doctor blocker failed", "exit_code": int(result.returncode)}, + ) + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + return step_result( + "doctor", + "failed", + [], + started_at, + { + "message": f"doctor JSON parse failed: {exc}", + "exit_code": 1, + }, + ) + + checks = payload.get("checks", []) + if isinstance(checks, list): + ctx.doctor_advisories[:] = [ + check + for check in checks + if isinstance(check, dict) + and check.get("severity") == "advisory" + and check.get("status") in ("warn", "fail") + ] + maybe_dead_end_on_port(ctx) + print(f"[step {step_index}/{TOTAL_STEPS}] doctor passed") + return step_result("doctor", "ok", [], started_at) + + +def maybe_dead_end_on_port(ctx: SetupContext) -> None: + if ( + ctx.skip_service + or ctx.port_supplied + or ctx.mode is not SetupMode.NON_INTERACTIVE + ): + return + for advisory in ctx.doctor_advisories: + if advisory.get("name") == "port_5015_free": + dead_end_port_in_use(ctx) + detail = advisory.get("detail") + if isinstance(detail, str) and f"port {ctx.port}" in detail: + dead_end_port_in_use(ctx) + + +def step_journal(ctx: SetupContext, step_index: int) -> StepResult: + started_at = utc_now() + print_step_header(step_index, "journal config") + if non_empty_journal(ctx.journal_path) and not ctx.accept_existing_journal: + if ctx.mode is SetupMode.NON_INTERACTIVE: + dead_end_existing_journal(ctx) + if not prompt_accept_existing_journal(ctx.journal_path): + raise SetupDeadEnd("setup aborted by user", 2) + + persisted = read_user_config().get("journal", "").strip() + persisted_matches = bool(persisted) and expand_path(persisted) == ctx.journal_path + if not persisted_matches: + write_user_config(journal=str(ctx.journal_path)) + print(f"[step {step_index}/{TOTAL_STEPS}] wrote {ctx.config_path}") + else: + print(f"[step {step_index}/{TOTAL_STEPS}] journal config already current") + ctx.journal_path.mkdir(parents=True, exist_ok=True) + return step_result( + "journal", + "ok", + [ctx.config_path, ctx.journal_path], + started_at, + ) + + +def prompt_accept_existing_journal(path: Path) -> bool: + answer = input(f"Use existing journal at {path}? [y/N]: ").strip().lower() + return answer in {"y", "yes"} + + +def linux_model_sentinel() -> Path: + return Path.home() / ".cache" / "huggingface" / "hub" / ".solstone-install-complete" + + +def mac_model_sentinel() -> Path: + return ( + Path.home() + / "Library" + / "Application Support" + / "solstone" + / "parakeet" + / "models" + / ".install-complete" + ) + + +def model_paths() -> list[Path]: + if sys.platform.startswith("linux"): + return [linux_model_sentinel()] + if sys.platform == "darwin": + return [mac_model_sentinel()] + return [] + + +def step_install_models(ctx: SetupContext, step_index: int) -> StepResult: + started_at = utc_now() + if ctx.skip_models: + print_step_skipped(step_index, "install_models", "--skip-models") + return step_result("install_models", "skipped", [], started_at) + command = install_models_command(ctx) + print_step_header(step_index, "install-models", command) + rc = run_inherited(command) + if rc != 0: + return step_result( + "install_models", + "failed", + model_paths(), + started_at, + {"message": "install-models failed", "exit_code": rc}, + ) + return step_result("install_models", "ok", model_paths(), started_at) + + +def step_skills(ctx: SetupContext, step_index: int) -> StepResult: + started_at = utc_now() + claude_dir = Path.home() / ".claude" + skill_path = claude_dir / "skills" / "solstone" / "SKILL.md" + if ctx.skip_skills: + print_step_skipped(step_index, "skills", "--skip-skills") + return step_result("skills", "skipped", [], started_at) + if not claude_dir.exists(): + reason = f"Claude Code config not found at {claude_dir}" + print_step_skipped(step_index, "skills", reason) + return step_result("skills", "skipped", [], started_at) + command = skills_command() + print_step_header(step_index, "skills", command) + rc = run_inherited(command) + if rc != 0: + return step_result( + "skills", + "failed", + [skill_path], + started_at, + {"message": "skills install failed", "exit_code": rc}, + ) + return step_result("skills", "ok", [skill_path], started_at) + + +def step_wrapper(ctx: SetupContext, step_index: int) -> StepResult: + started_at = utc_now() + wrapper_path = Path.home() / ".local" / "bin" / "sol" + if not ctx.is_source_checkout: + print_step_skipped(step_index, "wrapper", "packaged install") + return step_result("wrapper", "skipped", [], started_at) + command = wrapper_command() + print_step_header(step_index, "wrapper", command) + rc = run_inherited(command) + if rc != 0: + return step_result( + "wrapper", + "failed", + [wrapper_path], + started_at, + {"message": "wrapper install failed", "exit_code": rc}, + ) + return step_result("wrapper", "ok", [wrapper_path], started_at) + + +def service_artifact_path() -> Path | None: + if sys.platform == "darwin": + return Path.home() / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" + if sys.platform.startswith("linux"): + return Path.home() / ".config" / "systemd" / "user" / "solstone.service" + return None + + +def step_service(ctx: SetupContext, step_index: int) -> StepResult: + started_at = utc_now() + artifact = service_artifact_path() + paths = [artifact] if artifact is not None else [] + if ctx.skip_service: + print_step_skipped(step_index, "service", "--skip-service") + return step_result("service", "skipped", [], started_at) + if not ctx.is_source_checkout: + ctx.service_skipped_packaged = True + reason = ( + "packaged-install service support is not implemented in v1. " + "Setup completed the journal, model, and skill steps; run sol setup " + "again from a source checkout to install the background service." + ) + print_step_skipped(step_index, "service", reason) + return step_result("service", "skipped", [], started_at) + + command = service_install_command(ctx) + print_step_header(step_index, "service install", command) + rc = run_inherited(command) + if rc != 0: + return step_result( + "service", + "failed", + paths, + started_at, + {"message": "service install failed", "exit_code": rc}, + ) + + from think.service import _up + + print(f"[step {step_index}/{TOTAL_STEPS}] running service up...") + up_rc = int(_up(port=ctx.port)) + if up_rc != 0: + return step_result( + "service", + "failed", + paths, + started_at, + {"message": "service up failed", "exit_code": up_rc}, + ) + + from think.health_cli import health_check + + print(f"[step {step_index}/{TOTAL_STEPS}] waiting for health...") + for attempt in range(1, HEALTH_ATTEMPTS + 1): + if health_check() == 0: + return step_result("service", "ok", paths, started_at) + if attempt < HEALTH_ATTEMPTS: + time.sleep(HEALTH_SLEEP_SECONDS) + return step_result( + "service", + "failed", + paths, + started_at, + {"message": "service readiness timeout after 20s", "exit_code": 1}, + ) + + +def dead_end_existing_journal(ctx: SetupContext) -> None: + message = "\n".join( + [ + ( + "sol setup: cannot proceed in non-interactive mode - " + f"{ctx.journal_path} already contains journal data." + ), + "Setup will not auto-claim an existing journal.", + "", + "Retry with one of:", + " sol setup --accept-existing-journal", + " sol setup --journal /path/to/new-journal --accept-existing-journal", + "", + "Interactive escape:", + " sol setup", + "", + "Run 'sol setup --explain' for full step list.", + ] + ) + raise SetupDeadEnd(message, 2) + + +def dead_end_port_in_use(ctx: SetupContext) -> None: + message = "\n".join( + [ + ( + "sol setup: cannot proceed in non-interactive mode - " + f"port {ctx.port} is already in use." + ), + "Setup will not choose a different service port silently.", + "", + "Retry with one of:", + " sol setup --port ", + " sol setup --skip-service", + "", + "Interactive escape:", + " sol setup", + "", + "Run 'sol setup --explain' for full step list.", + ] + ) + raise SetupDeadEnd(message, 2) + + +def print_plan(ctx: SetupContext, *, dry_run: bool) -> None: + heading = "setup dry-run" if dry_run else "setup plan" + print(f"{heading}:") + print(f" mode: {ctx.mode.value}") + print(f" journal: {ctx.journal_path} ({ctx.journal_source})") + print(f" port: {ctx.port} ({ctx.port_source})") + print(f" variant: {ctx.variant} ({ctx.variant_source})") + print(f" source checkout: {ctx.is_source_checkout}") + print() + print("[step 1/6] doctor") + print(f" would run: {format_command(doctor_command(ctx))}") + print("[step 2/6] journal") + print(f" would write: {ctx.config_path}") + print(f" would use journal: {ctx.journal_path}") + print("[step 3/6] install_models") + if ctx.skip_models: + print(" skipped: --skip-models") + else: + print(f" would run: {format_command(install_models_command(ctx))}") + print("[step 4/6] skills") + if ctx.skip_skills: + print(" skipped: --skip-skills") + else: + print(f" would run: {format_command(skills_command())}") + print("[step 5/6] wrapper") + if not ctx.is_source_checkout: + print(" skipped: packaged install") + else: + print(f" would run: {format_command(wrapper_command())}") + print("[step 6/6] service") + if ctx.skip_service: + print(" skipped: --skip-service") + elif not ctx.is_source_checkout: + print(" skipped: packaged-install service support is not implemented in v1") + else: + print(f" would run: {format_command(service_install_command(ctx))}") + print(f" would call: think.service._up(port={ctx.port})") + print( + f" would call: think.health_cli.health_check() up to {HEALTH_ATTEMPTS} times" + ) + + +def print_failure(result: StepResult) -> None: + error = result.error or {} + message = error.get("message", "step failed") + print(f"sol setup: {result.name} failed: {message}", file=sys.stderr) + + +def print_success_summary(ctx: SetupContext, manifest: dict[str, Any]) -> None: + print() + print("solstone is set up.") + print() + print("artifacts:") + paths = artifact_paths(ctx, manifest) + if paths: + for path in paths: + print(f" {path}") + else: + print(" none") + if ctx.service_skipped_packaged: + print( + " service: skipped (packaged-install service support is not implemented in v1)" + ) + print() + if ctx.doctor_advisories: + print("advisories from doctor:") + for advisory in ctx.doctor_advisories: + detail = advisory.get("detail") + if detail: + print(f" - {detail}") + else: + print("advisories from doctor: none") + print() + if not ctx.skip_service and ctx.is_source_checkout: + print(f"solstone is running at http://localhost:{ctx.port}") + print() + print("next: run 'sol observer install' to start observing.") + + +def artifact_paths(ctx: SetupContext, manifest: dict[str, Any]) -> list[str]: + seen: set[str] = set() + paths: list[str] = [] + for step in manifest.get("steps", []): + if not isinstance(step, dict): + continue + for item in step.get("paths", []): + if not isinstance(item, str) or item in seen: + continue + seen.add(item) + paths.append(item) + manifest_path = absolute_string(ctx.manifest_path) + if ( + ctx.mode not in (SetupMode.DRY_RUN, SetupMode.EXPLAIN) + and manifest_path not in seen + ): + paths.append(manifest_path) + return paths + + +def run_setup(ctx: SetupContext) -> int: + if ctx.mode is SetupMode.EXPLAIN: + print_plan(ctx, dry_run=False) + return 0 + if ctx.mode is SetupMode.DRY_RUN: + print_plan(ctx, dry_run=True) + return 0 + + manifest = initial_manifest(ctx) + steps = [ + step_doctor, + step_journal, + step_install_models, + step_skills, + step_wrapper, + step_service, + ] + for index, step in enumerate(steps, start=1): + result = step(ctx, index) + append_step(manifest, result) + write_manifest(ctx, manifest) + if result.status == "failed": + print_failure(result) + error = result.error or {} + return int(error.get("exit_code", 1)) + + manifest["completed_at"] = utc_now() + write_manifest(ctx, manifest) + print_success_summary(ctx, manifest) + return 0 + + +def main(argv: list[str] | None = None) -> int: + raw_argv = list(argv) if argv is not None else sys.argv[1:] + parser = build_parser() + args = parser.parse_args(raw_argv) + ctx = resolve_context(args, raw_argv) + try: + return run_setup(ctx) + except SetupDeadEnd as exc: + print(exc.message, file=sys.stderr) + return exc.exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/think/sol_cli.py b/think/sol_cli.py index 2943a89ad..403204278 100644 --- a/think/sol_cli.py +++ b/think/sol_cli.py @@ -76,6 +76,7 @@ COMMANDS: dict[str, str] = { "restart-convey": "convey.restart", "maint": "convey.maint_cli", "service": "think.service", + "setup": "think.setup", } # ============================================================================= @@ -128,7 +129,7 @@ GROUPS: dict[str, list[str]] = { "restart-convey", "maint", ], - "Setup": ["install-models"], + "Setup": ["setup", "install-models"], "Specialized tools": [ "password", "config", -- 2.51.2