personal memory agent
0
fork

Configure Feed

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

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
`<journal>/.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) <noreply@anthropic.com>

+1363 -185
+4 -4
AGENTS.md
··· 134 134 135 135 ### Service management (systemd / launchd) 136 136 137 + `.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 <install|start|stop|restart|status|logs>` for manual service operations. 138 + 137 139 | Target | When to use | 138 140 |--------|-------------| 139 - | `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. | 140 - | `make uninstall-service` | Remove the installed service. | 141 141 | `make service-logs` | Tail the installed service's logs. | 142 142 143 143 ### Other ··· 151 151 152 152 | Target | Why not | 153 153 |--------|---------| 154 - | `make uninstall` | Disabled by design. Use `make uninstall-service` (for installed artifacts) or `make clean-install` (to rebuild the dev env). | 154 + | `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. | 155 155 156 156 ## 6. Testing quickstart 157 157 ··· 308 308 309 309 - **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. 310 310 - **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*. 311 - - **Not an operations manual.** For debugging a live system see `docs/DOCTOR.md`; for service management, the `make install-service` family. 311 + - **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`. 312 312 313 313 ## 13. Owner-facing copy: the system-anatomy canon 314 314
+5 -5
INSTALL.md
··· 8 8 9 9 ## before you begin 10 10 11 - `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. 11 + `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. 12 12 13 13 check if solstone is already installed and running: 14 14 ··· 87 87 ## start solstone 88 88 89 89 ```bash 90 - make install-service 90 + .venv/bin/sol setup 91 91 ``` 92 92 93 - 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. 93 + 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. 94 94 95 95 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. 96 96 ··· 117 117 ## updating after a code change 118 118 119 119 ```bash 120 - git pull && make install-service 120 + git pull && make install && .venv/bin/sol setup 121 121 ``` 122 122 123 - 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. 123 + `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. 124 124 125 125 ## done 126 126
+3 -96
Makefile
··· 14 14 PYTEST_BASETEMP_INIT := BASETEMP=$$(mktemp -d /var/tmp/solstone-pytest-XXXXXX); trap 'rm -rf "$$BASETEMP"' EXIT INT TERM; 15 15 PYTEST_BASETEMP_FLAG := --basetemp "$$BASETEMP" 16 16 17 - .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 17 + .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 18 18 19 19 # Default target - install package in editable mode 20 20 all: install ··· 28 28 29 29 # Require uv 30 30 UV := $(shell command -v uv 2>/dev/null) 31 - ifeq (,$(filter-out doctor,$(or $(MAKECMDGOALS),all))) 32 - # doctor-only invocation — skip uv requirement so a uv-less machine can run diagnostics 33 - else 34 31 ifndef UV 35 32 $(error uv is not installed. Install it: curl -LsSf https://astral.sh/uv/install.sh | sh) 36 - endif 37 33 endif 38 34 39 35 # Node — add nvm bin dir to PATH if npx isn't already available ··· 75 71 $(UV) lock 76 72 77 73 # Install package in editable mode with isolated venv 78 - install: doctor .installed 74 + install: .installed 79 75 @(cd /tmp && $(CURDIR)/$(VENV_BIN)/python -c "from think.sol_cli import main") 2>/dev/null || { \ 80 76 echo ">>> re-registering editable install"; \ 81 77 $(UV) pip install -e . --no-deps; \ ··· 413 409 find . -type f -name ".DS_Store" -delete 414 410 rm -f .installed 415 411 416 - # Pre-install diagnostic — stdlib-only; runs on system python without uv/venv 417 - doctor: 418 - @python3 scripts/doctor.py $(if $(VERBOSE),--verbose) $(if $(JSON),--json) $(if $(PORT),--port $(PORT)) 419 - 420 - # Service management (override port: make install-service PORT=8000) 421 - install-service: doctor .installed 422 - @MODE=$$($(PYTHON) -m think.install_guard check); \ 423 - RC=$$?; \ 424 - case "$$MODE" in \ 425 - worktree) \ 426 - echo "mode: aborted — worktree"; \ 427 - exit $$RC; \ 428 - ;; \ 429 - cross_repo) \ 430 - echo "mode: aborted — cross_repo"; \ 431 - exit $$RC; \ 432 - ;; \ 433 - dangling) \ 434 - echo "mode: aborted — dangling"; \ 435 - exit $$RC; \ 436 - ;; \ 437 - not_symlink) \ 438 - echo "mode: aborted — not_symlink"; \ 439 - exit $$RC; \ 440 - ;; \ 441 - up""grade) \ 442 - echo "mode: up""grade"; \ 443 - ;; \ 444 - current) \ 445 - echo "mode: current"; \ 446 - ;; \ 447 - fresh) \ 448 - echo "mode: fresh install"; \ 449 - ;; \ 450 - *) \ 451 - echo "mode: aborted — unknown"; \ 452 - exit 2; \ 453 - ;; \ 454 - esac; \ 455 - $(PYTHON) -m think.install_guard install; \ 456 - $(VENV_BIN)/sol skills install; \ 457 - $(VENV_BIN)/sol service install --port $(or $(PORT),5015); \ 458 - $(VENV_BIN)/sol service restart; \ 459 - echo "Waiting for supervisor to report healthy..."; \ 460 - READY=false; \ 461 - for i in $$(seq 1 20); do \ 462 - if $(VENV_BIN)/sol health > /dev/null 2>&1; then \ 463 - READY=true; \ 464 - break; \ 465 - fi; \ 466 - printf .; \ 467 - sleep 1; \ 468 - done; \ 469 - if [ "$$READY" = "true" ]; then \ 470 - printf '\n'; \ 471 - echo "Service is healthy."; \ 472 - else \ 473 - printf '\n' >&2; \ 474 - echo "Service readiness timeout after 20s" >&2; \ 475 - exit 1; \ 476 - fi; \ 477 - $(VENV_BIN)/sol service status 478 - 479 412 # Follow installed service logs 480 413 service-logs: 481 414 $(VENV_BIN)/sol service logs -f 482 415 483 - uninstall-service: 484 - @MODE=$$($(PYTHON) -m think.install_guard check); \ 485 - RC=$$?; \ 486 - HAS_SERVICE=false; \ 487 - HAS_SKILL=false; \ 488 - if [ -f "$$HOME/.config/systemd/user/solstone.service" ] || [ -f "$$HOME/Library/LaunchAgents/org.solpbc.solstone.plist" ]; then \ 489 - HAS_SERVICE=true; \ 490 - fi; \ 491 - if [ -e "$$HOME/.claude/skills/solstone" ]; then \ 492 - HAS_SKILL=true; \ 493 - fi; \ 494 - case "$$MODE" in \ 495 - worktree|cross_repo|dangling|not_symlink) \ 496 - echo "mode: aborted — $$MODE"; \ 497 - exit $$RC; \ 498 - ;; \ 499 - esac; \ 500 - if [ "$$MODE" = "fresh" ] && [ "$$HAS_SERVICE" = "false" ] && [ "$$HAS_SKILL" = "false" ]; then \ 501 - echo "no artifacts to remove"; \ 502 - exit 0; \ 503 - fi; \ 504 - $(VENV_BIN)/sol service stop > /dev/null 2>&1 || true; \ 505 - $(VENV_BIN)/sol service uninstall; \ 506 - $(VENV_BIN)/sol skills uninstall; \ 507 - $(PYTHON) -m think.install_guard uninstall 508 - 509 416 uninstall: 510 - @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 417 + @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 511 418 @exit 1 512 419 513 420 FORCE:
+1 -1
README.md
··· 77 77 # See docs/INSTALL.md for setup instructions 78 78 79 79 # Install the CLI on PATH and start the background service (port 5015) 80 - make install-service 80 + .venv/bin/sol setup 81 81 82 82 # Or start manually for development 83 83 sol supervisor
+9 -6
docs/INSTALL.md
··· 51 51 52 52 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. 53 53 54 - To remove installed user/system artifacts: 54 + To remove installed user/system artifacts later: 55 55 56 56 ```bash 57 - make uninstall-service 57 + sol service stop 58 + sol service uninstall 59 + sol skills uninstall 60 + python -m think.install_guard uninstall 58 61 ``` 59 62 60 63 To reset the repo-local development environment: ··· 130 133 131 134 ### Install as a Background Service 132 135 133 - The recommended way to run solstone is as a system service that starts automatically on login: 136 + The recommended way to run solstone is through setup, which installs the runtime artifacts and starts the service: 134 137 135 138 ```bash 136 - make install-service 139 + .venv/bin/sol setup 137 140 ``` 138 141 139 - 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: 142 + 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: 140 143 141 144 ```bash 142 - make install-service PORT=8000 145 + .venv/bin/sol setup --port 8000 143 146 ``` 144 147 145 148 Manage the service with:
+1 -1
docs/environment.md
··· 30 30 31 31 ## Service Installation 32 32 33 - `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`. 33 + 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. 34 34 35 35 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`. 36 36
+2 -2
observe/observer_install/linux.py
··· 225 225 registration = create_or_reuse_registration(name, force=args.force) 226 226 _write_config(server_url, registration.key, name) 227 227 run_step( 228 - "run make install-service", 228 + "run observer install-service target", 229 229 ["make", "install-service"], 230 230 cwd=clone_dir, 231 231 json_output=args.json_output, ··· 545 545 print(f" would clone {SOURCE_URL} into {clone_dir}") 546 546 print(f" would create observer registration '{name}'") 547 547 print(f" would write {CONFIG_PATH}") 548 - print(" would run: make install-service") 548 + print(" would run observer install-service target") 549 549 print(" would wait up to 30s for observer status") 550 550 print(f" would write marker {marker_path(INSTALL_NAME)}") 551 551 print()
+2 -2
observe/observer_install/tmux.py
··· 124 124 registration = create_or_reuse_registration(name, force=args.force) 125 125 _write_config(server_url, registration.key, name) 126 126 run_step( 127 - "run make install-service", 127 + "run observer install-service target", 128 128 ["make", "install-service"], 129 129 cwd=clone_dir, 130 130 json_output=args.json_output, ··· 372 372 print(f" would clone {SOURCE_URL} into {clone_dir}") 373 373 print(f" would create observer registration '{name}'") 374 374 print(f" would write {CONFIG_PATH}") 375 - print(" would run: make install-service") 375 + print(" would run observer install-service target") 376 376 print(" would wait up to 30s for observer status") 377 377 print(f" would write marker {marker_path(INSTALL_NAME)}") 378 378 print()
+3 -3
scripts/doctor.py
··· 3 3 # Copyright (c) 2026 sol pbc 4 4 """Stdlib-only bootstrap shim for `sol doctor`. 5 5 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. 6 + Used as a pre-install entry point on machines that do not yet have `.venv` 7 + populated. Delegates to `think.doctor.main`, which holds the canonical 8 + diagnostic logic. 9 9 """ 10 10 11 11 from __future__ import annotations
+2 -2
skills/solstone/SKILL.md
··· 23 23 sol help 24 24 ``` 25 25 26 - If this fails, solstone is not installed. Install it from the solstone project: `make install-service`. 26 + If this fails, solstone is not installed. Install it from the solstone project: `sol setup`. 27 27 28 28 ## Capabilities 29 29 ··· 179 179 180 180 If `sol` is not found on PATH or returns an error: 181 181 182 - - **"command not found: sol"** — solstone is not installed. The user needs to run `make install-service` in their solstone project. 182 + - **"command not found: sol"** — solstone is not installed. The user needs to run `sol setup` in their solstone project. 183 183 - **"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. 184 184 - **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. 185 185
+1 -1
tests/observer_install/snapshots/linux_dry_run.txt
··· 31 31 would clone https://github.com/solpbc/solstone-linux.git into /home/jer/.local/share/solstone/observers/solstone-linux 32 32 would create observer registration 'archon' 33 33 would write /home/jer/.local/share/solstone-linux/config/config.json 34 - would run: make install-service 34 + would run observer install-service target 35 35 would wait up to 30s for observer status 36 36 would write marker /home/jer/.local/share/solstone/observers/solstone-linux/.installed.json 37 37
+1 -1
tests/observer_install/snapshots/tmux_dry_run.txt
··· 21 21 would clone https://github.com/solpbc/solstone-tmux.git into /home/jer/.local/share/solstone/observers/solstone-tmux 22 22 would create observer registration 'archon' 23 23 would write /home/jer/.local/share/solstone-tmux/config/config.json 24 - would run: make install-service 24 + would run observer install-service target 25 25 would wait up to 30s for observer status 26 26 would write marker /home/jer/.local/share/solstone/observers/solstone-tmux/.installed.json 27 27
+14 -9
tests/observer_install/test_linux.py
··· 87 87 return subprocess.CompletedProcess(cmd, 0, f"{linux.SOURCE_URL}\n", "") 88 88 return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 89 89 90 - steps: list[str] = [] 90 + steps: list[tuple[str, list[str]]] = [] 91 91 92 92 def fake_step(label, cmd, **kwargs): 93 - steps.append(label) 93 + steps.append((label, cmd)) 94 94 return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 95 95 96 96 monkeypatch.setattr(linux, "run_probe", fake_probe) ··· 98 98 99 99 assert linux.LinuxDriver().run(args_factory()) == 0 100 100 101 - assert "run make install-service" in steps 101 + assert ("run observer install-service target", ["make", "install-service"]) in steps 102 102 config = json.loads(linux.CONFIG_PATH.read_text(encoding="utf-8")) 103 103 assert config["server_url"] == "http://127.0.0.1:5015" 104 104 assert config["stream"] == "archon" ··· 151 151 return subprocess.CompletedProcess(cmd, 0, "active\n", "") 152 152 return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 153 153 154 - steps: list[str] = [] 154 + steps: list[tuple[str, list[str]]] = [] 155 155 156 156 def fake_step(label, cmd, **kwargs): 157 - steps.append(label) 157 + steps.append((label, cmd)) 158 158 return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 159 159 160 160 monkeypatch.setattr(linux, "run_probe", fake_probe) ··· 162 162 163 163 assert linux.LinuxDriver().run(args_factory()) == 0 164 164 165 - assert "run make install-service" not in steps 165 + assert all(label != "run observer install-service target" for label, _cmd in steps) 166 166 assert "already installed" in capsys.readouterr().out 167 167 assert common.read_marker(linux.INSTALL_NAME)["last_run"] == "2026-05-02T00:00:00Z" 168 168 ··· 185 185 return subprocess.CompletedProcess(cmd, 0, "active\n", "") 186 186 return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 187 187 188 - steps: list[str] = [] 188 + steps: list[tuple[str, list[str]]] = [] 189 189 190 190 def fake_step(label, cmd, **kwargs): 191 - steps.append(label) 191 + steps.append((label, cmd)) 192 192 if label.startswith("clone "): 193 193 (common.xdg_install_dir(linux.INSTALL_NAME) / ".git").mkdir(parents=True) 194 194 return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) ··· 216 216 assert linux.LinuxDriver().run(args_factory()) == 0 217 217 assert linux.LinuxDriver().run(args_factory()) == 0 218 218 219 - assert steps.count("run make install-service") == 1 219 + assert ( 220 + steps.count( 221 + ("run observer install-service target", ["make", "install-service"]) 222 + ) 223 + == 1 224 + ) 220 225 assert config_writes == 1 221 226 assert marker_writes == 1 222 227 assert "already installed" in capsys.readouterr().out
+11 -6
tests/observer_install/test_tmux.py
··· 18 18 return subprocess.CompletedProcess(cmd, 0, "tmuxsha\n", "") 19 19 return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 20 20 21 - steps: list[str] = [] 21 + steps: list[tuple[str, list[str]]] = [] 22 22 23 23 def fake_step(label, cmd, **kwargs): 24 - steps.append(label) 24 + steps.append((label, cmd)) 25 25 return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) 26 26 27 27 monkeypatch.setattr(tmux, "run_probe", fake_probe) ··· 29 29 30 30 assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 31 31 32 - assert "run make install-service" in steps 32 + assert ("run observer install-service target", ["make", "install-service"]) in steps 33 33 config = json.loads(tmux.CONFIG_PATH.read_text(encoding="utf-8")) 34 34 assert config["stream"] == "archon" 35 35 assert config["status_indicator"] is True ··· 93 93 return subprocess.CompletedProcess(cmd, 0, "active\n", "") 94 94 return subprocess.CompletedProcess(cmd, 0, "ok\n", "") 95 95 96 - steps: list[str] = [] 96 + steps: list[tuple[str, list[str]]] = [] 97 97 98 98 def fake_step(label, cmd, **kwargs): 99 - steps.append(label) 99 + steps.append((label, cmd)) 100 100 if label.startswith("clone "): 101 101 (common.xdg_install_dir(tmux.INSTALL_NAME) / ".git").mkdir(parents=True) 102 102 return common.StepResult(subprocess.CompletedProcess(cmd, 0, "", "")) ··· 124 124 assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 125 125 assert tmux.TmuxDriver().run(args_factory(platform="tmux")) == 0 126 126 127 - assert steps.count("run make install-service") == 1 127 + assert ( 128 + steps.count( 129 + ("run observer install-service target", ["make", "install-service"]) 130 + ) 131 + == 1 132 + ) 128 133 assert config_writes == 1 129 134 assert marker_writes == 1 130 135 assert "already installed" in capsys.readouterr().out
+2 -2
tests/test_config_cli.py
··· 241 241 242 242 assert rc == 1 243 243 assert captured.out == "" 244 - assert "make install-service" in captured.err 244 + assert "sol setup" in captured.err 245 245 246 246 247 247 def test_journal_refuses_legacy_symlink(home_root, tmp_path, capsys): ··· 253 253 254 254 assert rc == 1 255 255 assert captured.out == "" 256 - assert "make install-service" in captured.err 256 + assert "sol setup" in captured.err 257 257 258 258 259 259 def test_journal_refuses_invalid_chars(home_root, capsys):
+3 -35
tests/test_doctor.py
··· 6 6 import json 7 7 import os 8 8 import plistlib 9 - import shutil 10 - import socket 11 9 import subprocess 12 10 import sys 13 11 from pathlib import Path ··· 688 686 689 687 690 688 class TestMakefileIntegration: 691 - def test_dry_run_orders_doctor_before_uv_sync(self): 689 + def test_dry_run_install_does_not_run_doctor(self): 692 690 result = subprocess.run( 693 691 ["make", "--dry-run", "-B", "install"], 694 692 cwd=ROOT, ··· 699 697 ) 700 698 assert result.returncode == 0 701 699 lines = result.stdout.splitlines() 702 - doctor_idx = next( 703 - index 704 - for index, line in enumerate(lines) 705 - if "python3 scripts/doctor.py" in line 706 - ) 707 - uv_idx = next(index for index, line in enumerate(lines) if "uv sync" in line) 708 - assert doctor_idx < uv_idx 709 - 710 - def test_install_service_aborts_before_running_when_doctor_fails(self, tmp_path): 711 - if shutil.which("lsof") is None: 712 - pytest.skip("lsof not available") 713 - installed = ROOT / ".installed" 714 - if not installed.exists(): 715 - pytest.skip(".installed missing") 716 - before = installed.stat().st_mtime 717 - with socket.socket() as server: 718 - server.bind(("127.0.0.1", 0)) 719 - server.listen(1) 720 - port = server.getsockname()[1] 721 - env = os.environ.copy() 722 - env["HOME"] = str(tmp_path / "home") 723 - result = subprocess.run( 724 - ["make", "install-service", f"PORT={port}"], 725 - cwd=ROOT, 726 - capture_output=True, 727 - text=True, 728 - check=False, 729 - timeout=15, 730 - env=env, 731 - ) 732 - assert result.returncode != 0 733 - assert installed.stat().st_mtime == before 700 + assert all("python3 scripts/doctor.py" not in line for line in lines) 701 + assert any("uv sync" in line for line in lines)
+1 -1
tests/test_install_guard.py
··· 197 197 "ERROR: Another solstone install owns ~/.local/bin/sol.\n" 198 198 f" this repo: {curdir}\n" 199 199 f"{installed}\n" 200 - "Run 'make uninstall-service' from the installed repo first,\n" 200 + "Run 'sol setup' from the installed repo first,\n" 201 201 "or remove ~/.local/bin/sol manually if that repo is gone.\n" 202 202 ) 203 203 if allow_force:
+411
tests/test_setup.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import subprocess 8 + import sys 9 + from pathlib import Path 10 + from typing import Any 11 + 12 + import pytest 13 + 14 + from think import health_cli, service, setup 15 + 16 + 17 + def patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: 18 + home = tmp_path / "home" 19 + home.mkdir() 20 + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) 21 + return home 22 + 23 + 24 + def patch_source_checkout(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: 25 + repo = tmp_path / "repo" 26 + repo.mkdir() 27 + (repo / "pyproject.toml").write_text("[project]\nname = 'solstone'\n") 28 + (repo / ".git").mkdir() 29 + monkeypatch.setattr(setup, "get_project_root", lambda: str(repo)) 30 + return repo 31 + 32 + 33 + def patch_packaged_install(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: 34 + root = tmp_path / "site-packages" 35 + root.mkdir() 36 + monkeypatch.setattr(setup, "get_project_root", lambda: str(root)) 37 + return root 38 + 39 + 40 + def patch_tty(monkeypatch: pytest.MonkeyPatch) -> None: 41 + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) 42 + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) 43 + 44 + 45 + def doctor_payload(checks: list[dict[str, Any]] | None = None) -> str: 46 + return json.dumps( 47 + { 48 + "checks": checks or [], 49 + "summary": { 50 + "total": len(checks or []), 51 + "failed": 0, 52 + "warnings": 0, 53 + "skipped": 0, 54 + }, 55 + } 56 + ) 57 + 58 + 59 + def patch_subprocess( 60 + monkeypatch: pytest.MonkeyPatch, 61 + *, 62 + doctor_stdout: str | None = None, 63 + doctor_returncode: int = 0, 64 + command_returncode: int = 0, 65 + ) -> list[list[str]]: 66 + calls: list[list[str]] = [] 67 + 68 + def fake_run( 69 + command: list[str], **kwargs: object 70 + ) -> subprocess.CompletedProcess[str]: 71 + calls.append(command) 72 + if "doctor" in command: 73 + return subprocess.CompletedProcess( 74 + command, 75 + doctor_returncode, 76 + stdout=doctor_stdout if doctor_stdout is not None else doctor_payload(), 77 + stderr="doctor failed\n" if doctor_returncode else "", 78 + ) 79 + return subprocess.CompletedProcess(command, command_returncode) 80 + 81 + monkeypatch.setattr(setup.subprocess, "run", fake_run) 82 + return calls 83 + 84 + 85 + def patch_service_health(monkeypatch: pytest.MonkeyPatch) -> None: 86 + monkeypatch.setattr(service, "_up", lambda port=5015: 0) 87 + monkeypatch.setattr(health_cli, "health_check", lambda: 0) 88 + 89 + 90 + def command_contains(calls: list[list[str]], *parts: str) -> bool: 91 + return any(all(part in command for part in parts) for command in calls) 92 + 93 + 94 + def read_manifest(journal: Path) -> dict[str, Any]: 95 + return json.loads((journal / ".setup-state.json").read_text(encoding="utf-8")) 96 + 97 + 98 + def test_interactive_happy_path_default_journal( 99 + tmp_path: Path, 100 + monkeypatch: pytest.MonkeyPatch, 101 + capsys: pytest.CaptureFixture[str], 102 + ) -> None: 103 + home = patch_home(monkeypatch, tmp_path) 104 + patch_source_checkout(monkeypatch, tmp_path) 105 + patch_tty(monkeypatch) 106 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 107 + (home / ".claude").mkdir() 108 + calls = patch_subprocess(monkeypatch) 109 + patch_service_health(monkeypatch) 110 + 111 + rc = setup.main([]) 112 + 113 + assert rc == 0 114 + journal = home / "Documents" / "journal" 115 + assert (home / ".config" / "solstone" / "config.toml").read_text( 116 + encoding="utf-8" 117 + ) == f'journal = "{journal}"\n' 118 + manifest = read_manifest(journal) 119 + assert [step["name"] for step in manifest["steps"]] == [ 120 + "doctor", 121 + "journal", 122 + "install_models", 123 + "skills", 124 + "wrapper", 125 + "service", 126 + ] 127 + assert "solstone is running at http://localhost:5015" in capsys.readouterr().out 128 + assert command_contains(calls, "install-models") 129 + assert command_contains(calls, "skills", "claude") 130 + assert command_contains(calls, "think.install_guard", "install") 131 + 132 + 133 + def test_interactive_happy_path_journal_override( 134 + tmp_path: Path, 135 + monkeypatch: pytest.MonkeyPatch, 136 + ) -> None: 137 + home = patch_home(monkeypatch, tmp_path) 138 + patch_source_checkout(monkeypatch, tmp_path) 139 + patch_tty(monkeypatch) 140 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 141 + (home / ".claude").mkdir() 142 + calls = patch_subprocess(monkeypatch) 143 + patch_service_health(monkeypatch) 144 + journal = tmp_path / "custom-journal" 145 + 146 + rc = setup.main(["--journal", str(journal)]) 147 + 148 + assert rc == 0 149 + assert (home / ".config" / "solstone" / "config.toml").read_text( 150 + encoding="utf-8" 151 + ) == f'journal = "{journal}"\n' 152 + assert read_manifest(journal)["args_resolved"]["journal"]["source"] == "cli" 153 + assert command_contains(calls, "think.install_guard", "install") 154 + 155 + 156 + def test_non_interactive_happy_path( 157 + tmp_path: Path, 158 + monkeypatch: pytest.MonkeyPatch, 159 + ) -> None: 160 + home = patch_home(monkeypatch, tmp_path) 161 + patch_source_checkout(monkeypatch, tmp_path) 162 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 163 + (home / ".claude").mkdir() 164 + calls = patch_subprocess(monkeypatch) 165 + patch_service_health(monkeypatch) 166 + journal = tmp_path / "journal" 167 + 168 + rc = setup.main(["--yes", "--journal", str(journal)]) 169 + 170 + assert rc == 0 171 + manifest = read_manifest(journal) 172 + assert manifest["completed_at"] is not None 173 + assert len(manifest["steps"]) == 6 174 + assert command_contains(calls, "service", "install") 175 + 176 + 177 + @pytest.mark.parametrize("use_journal_flag", [False, True]) 178 + def test_non_interactive_dead_end_on_existing_journal( 179 + tmp_path: Path, 180 + monkeypatch: pytest.MonkeyPatch, 181 + capsys: pytest.CaptureFixture[str], 182 + use_journal_flag: bool, 183 + ) -> None: 184 + home = patch_home(monkeypatch, tmp_path) 185 + patch_source_checkout(monkeypatch, tmp_path) 186 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 187 + calls = patch_subprocess(monkeypatch) 188 + patch_service_health(monkeypatch) 189 + journal = ( 190 + tmp_path / "journal" if use_journal_flag else home / "Documents" / "journal" 191 + ) 192 + (journal / "config").mkdir(parents=True) 193 + argv = ["--yes"] 194 + if use_journal_flag: 195 + argv.extend(["--journal", str(journal)]) 196 + 197 + rc = setup.main(argv) 198 + 199 + assert rc == 2 200 + err = capsys.readouterr().err 201 + assert "already contains journal data" in err 202 + assert "--accept-existing-journal" in err 203 + assert not command_contains(calls, "install-models") 204 + assert not command_contains(calls, "skills") 205 + assert not command_contains(calls, "think.install_guard") 206 + assert not command_contains(calls, "service", "install") 207 + 208 + 209 + def test_dry_run_side_effect_free( 210 + tmp_path: Path, 211 + monkeypatch: pytest.MonkeyPatch, 212 + capsys: pytest.CaptureFixture[str], 213 + ) -> None: 214 + home = patch_home(monkeypatch, tmp_path) 215 + patch_source_checkout(monkeypatch, tmp_path) 216 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 217 + calls = patch_subprocess(monkeypatch) 218 + journal = tmp_path / "journal" 219 + 220 + rc = setup.main(["--dry-run", "--journal", str(journal)]) 221 + 222 + assert rc == 0 223 + assert calls == [] 224 + assert not (home / ".config" / "solstone" / "config.toml").exists() 225 + assert not (journal / ".setup-state.json").exists() 226 + assert "setup dry-run:" in capsys.readouterr().out 227 + 228 + 229 + def test_explain_early_exit( 230 + tmp_path: Path, 231 + monkeypatch: pytest.MonkeyPatch, 232 + capsys: pytest.CaptureFixture[str], 233 + ) -> None: 234 + patch_home(monkeypatch, tmp_path) 235 + patch_source_checkout(monkeypatch, tmp_path) 236 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 237 + calls = patch_subprocess(monkeypatch) 238 + 239 + rc = setup.main(["--explain"]) 240 + 241 + assert rc == 0 242 + assert calls == [] 243 + assert "setup plan:" in capsys.readouterr().out 244 + 245 + 246 + def test_manifest_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 247 + home = patch_home(monkeypatch, tmp_path) 248 + patch_source_checkout(monkeypatch, tmp_path) 249 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 250 + (home / ".claude").mkdir() 251 + patch_subprocess(monkeypatch) 252 + patch_service_health(monkeypatch) 253 + journal = tmp_path / "journal" 254 + 255 + rc = setup.main(["--yes", "--journal", str(journal)]) 256 + 257 + assert rc == 0 258 + manifest = read_manifest(journal) 259 + assert manifest["schema_version"] == 1 260 + assert manifest["mode"] == "non_interactive" 261 + assert all( 262 + Path(path).is_absolute() for step in manifest["steps"] for path in step["paths"] 263 + ) 264 + assert {step["status"] for step in manifest["steps"]} <= {"ok", "skipped", "failed"} 265 + 266 + 267 + def test_idempotent_rerun_short_circuits( 268 + tmp_path: Path, 269 + monkeypatch: pytest.MonkeyPatch, 270 + ) -> None: 271 + home = patch_home(monkeypatch, tmp_path) 272 + patch_source_checkout(monkeypatch, tmp_path) 273 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 274 + (home / ".claude").mkdir() 275 + journal = tmp_path / "journal" 276 + journal.mkdir() 277 + (journal / ".setup-state.json").write_text( 278 + json.dumps({"schema_version": 1, "completed_at": "2026-05-02T21:30:42Z"}), 279 + encoding="utf-8", 280 + ) 281 + calls = patch_subprocess(monkeypatch) 282 + patch_service_health(monkeypatch) 283 + 284 + rc = setup.main(["--yes", "--journal", str(journal)]) 285 + 286 + assert rc == 0 287 + assert command_contains(calls, "doctor") 288 + assert command_contains(calls, "install-models") 289 + assert command_contains(calls, "think.install_guard") 290 + assert read_manifest(journal)["completed_at"] is not None 291 + 292 + 293 + def test_partial_completion_resumption( 294 + tmp_path: Path, 295 + monkeypatch: pytest.MonkeyPatch, 296 + ) -> None: 297 + home = patch_home(monkeypatch, tmp_path) 298 + patch_source_checkout(monkeypatch, tmp_path) 299 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 300 + (home / ".claude").mkdir() 301 + journal = tmp_path / "journal" 302 + journal.mkdir() 303 + (journal / ".setup-state.json").write_text( 304 + json.dumps( 305 + { 306 + "schema_version": 1, 307 + "steps": [ 308 + {"name": "doctor", "status": "ok"}, 309 + {"name": "service", "status": "failed"}, 310 + ], 311 + } 312 + ), 313 + encoding="utf-8", 314 + ) 315 + calls = patch_subprocess(monkeypatch) 316 + patch_service_health(monkeypatch) 317 + 318 + rc = setup.main(["--yes", "--journal", str(journal)]) 319 + 320 + assert rc == 0 321 + assert [step["name"] for step in read_manifest(journal)["steps"]] == [ 322 + "doctor", 323 + "journal", 324 + "install_models", 325 + "skills", 326 + "wrapper", 327 + "service", 328 + ] 329 + assert command_contains(calls, "service", "install") 330 + 331 + 332 + def test_port_in_use_default_non_interactive_dead_end( 333 + tmp_path: Path, 334 + monkeypatch: pytest.MonkeyPatch, 335 + capsys: pytest.CaptureFixture[str], 336 + ) -> None: 337 + patch_home(monkeypatch, tmp_path) 338 + patch_source_checkout(monkeypatch, tmp_path) 339 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 340 + calls = patch_subprocess( 341 + monkeypatch, 342 + doctor_stdout=doctor_payload( 343 + [ 344 + { 345 + "name": "port_5015_free", 346 + "severity": "advisory", 347 + "status": "warn", 348 + "detail": "port 5015 is in use by pid 123", 349 + "fix": "kill 123", 350 + } 351 + ] 352 + ), 353 + ) 354 + journal = tmp_path / "journal" 355 + 356 + rc = setup.main(["--yes", "--journal", str(journal)]) 357 + 358 + assert rc == 2 359 + assert "port 5015 is already in use" in capsys.readouterr().err 360 + assert command_contains(calls, "doctor") 361 + assert not command_contains(calls, "install-models") 362 + 363 + 364 + def test_packaged_install_skips_service( 365 + tmp_path: Path, 366 + monkeypatch: pytest.MonkeyPatch, 367 + capsys: pytest.CaptureFixture[str], 368 + ) -> None: 369 + patch_home(monkeypatch, tmp_path) 370 + patch_packaged_install(monkeypatch, tmp_path) 371 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 372 + calls = patch_subprocess(monkeypatch) 373 + patch_service_health(monkeypatch) 374 + journal = tmp_path / "journal" 375 + 376 + rc = setup.main( 377 + ["--yes", "--journal", str(journal), "--skip-models", "--skip-skills"] 378 + ) 379 + 380 + assert rc == 0 381 + out = capsys.readouterr().out 382 + assert "packaged-install service support is not implemented in v1" in out 383 + assert not command_contains(calls, "think.install_guard") 384 + assert not command_contains(calls, "service", "install") 385 + assert [step["status"] for step in read_manifest(journal)["steps"]][-2:] == [ 386 + "skipped", 387 + "skipped", 388 + ] 389 + 390 + 391 + def test_no_claude_config_skips_skills( 392 + tmp_path: Path, 393 + monkeypatch: pytest.MonkeyPatch, 394 + capsys: pytest.CaptureFixture[str], 395 + ) -> None: 396 + patch_home(monkeypatch, tmp_path) 397 + patch_source_checkout(monkeypatch, tmp_path) 398 + monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) 399 + calls = patch_subprocess(monkeypatch) 400 + patch_service_health(monkeypatch) 401 + journal = tmp_path / "journal" 402 + 403 + rc = setup.main(["--yes", "--journal", str(journal), "--skip-models"]) 404 + 405 + assert rc == 0 406 + assert "Claude Code config not found" in capsys.readouterr().out 407 + assert not command_contains(calls, "skills", "install") 408 + skill_step = next( 409 + step for step in read_manifest(journal)["steps"] if step["name"] == "skills" 410 + ) 411 + assert skill_step["status"] == "skipped"
+2 -2
think/config_cli.py
··· 105 105 def _wrapper_refusal(alias: Path) -> str: 106 106 return ( 107 107 "sol config: refused: " 108 - f"{alias} is not a managed wrapper (run 'make install-service' to " 109 - "install the wrapper first)" 108 + f"{alias} is not a managed wrapper (run 'sol setup' from the solstone " 109 + "source checkout to install the wrapper first)" 110 110 ) 111 111 112 112
+4 -4
think/doctor.py
··· 657 657 check, 658 658 "fail", 659 659 detail, 660 - "run `make uninstall-service` from the installed repo, or remove `~/.local/bin/sol` manually if the repo is gone", 660 + "run `sol setup` from the repo that owns the wrapper, or remove `~/.local/bin/sol` manually if the repo is gone", 661 661 ) 662 662 663 663 ··· 735 735 check, 736 736 "fail", 737 737 f"could not parse plist: {type(exc).__name__}: {exc}", 738 - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 738 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", 739 739 ) 740 740 program_arguments = data.get("ProgramArguments") 741 741 if not isinstance(program_arguments, list) or not program_arguments: ··· 743 743 check, 744 744 "fail", 745 745 "plist is missing ProgramArguments[0]", 746 - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 746 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", 747 747 ) 748 748 executable = Path(str(program_arguments[0])) 749 749 if not executable.exists(): ··· 751 751 check, 752 752 "fail", 753 753 f"plist points to missing executable: {executable}", 754 - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && make install-service", 754 + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", 755 755 ) 756 756 return make_result(check, "ok", f"launchd plist target exists ({executable})") 757 757
+1 -1
think/install_guard.py
··· 226 226 "ERROR: Another solstone install owns ~/.local/bin/sol.", 227 227 f" this repo: {curdir}", 228 228 installed, 229 - "Run 'make uninstall-service' from the installed repo first,", 229 + "Run 'sol setup' from the installed repo first,", 230 230 "or remove ~/.local/bin/sol manually if that repo is gone.", 231 231 ] 232 232 if allow_force:
+878
think/setup.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """User-runtime setup orchestration for solstone.""" 5 + 6 + from __future__ import annotations 7 + 8 + import argparse 9 + import json 10 + import logging 11 + import os 12 + import subprocess 13 + import sys 14 + import tempfile 15 + import time 16 + from dataclasses import asdict, dataclass 17 + from datetime import datetime, timezone 18 + from enum import Enum 19 + from pathlib import Path 20 + from typing import Any, Literal 21 + 22 + from think.user_config import ( 23 + config_path, 24 + default_journal, 25 + read_user_config, 26 + write_user_config, 27 + ) 28 + from think.utils import get_project_root 29 + 30 + TOTAL_STEPS = 6 31 + MANIFEST_SCHEMA_VERSION = 1 32 + HEALTH_ATTEMPTS = 20 33 + HEALTH_SLEEP_SECONDS = 1.0 34 + 35 + StepStatus = Literal["ok", "skipped", "failed"] 36 + 37 + 38 + class SetupMode(Enum): 39 + INTERACTIVE = "interactive" 40 + NON_INTERACTIVE = "non_interactive" 41 + DRY_RUN = "dry_run" 42 + EXPLAIN = "explain" 43 + 44 + 45 + @dataclass 46 + class SetupContext: 47 + mode: SetupMode 48 + project_root: Path 49 + is_source_checkout: bool 50 + journal_path: Path 51 + journal_source: str 52 + config_path: Path 53 + manifest_path: Path 54 + port: int 55 + port_source: str 56 + port_supplied: bool 57 + variant: str 58 + variant_source: str 59 + yes: bool 60 + skip_models: bool 61 + skip_skills: bool 62 + skip_service: bool 63 + accept_existing_journal: bool 64 + stdin_is_tty: bool 65 + stdout_is_tty: bool 66 + args_resolved: dict[str, object] 67 + doctor_advisories: list[dict[str, Any]] 68 + service_skipped_packaged: bool = False 69 + 70 + 71 + @dataclass(frozen=True) 72 + class StepResult: 73 + name: str 74 + status: StepStatus 75 + paths: list[str] 76 + started_at: str 77 + finished_at: str 78 + error: dict[str, object] | None 79 + 80 + 81 + class SetupDeadEnd(Exception): 82 + def __init__(self, message: str, exit_code: int = 2) -> None: 83 + super().__init__(message) 84 + self.message = message 85 + self.exit_code = exit_code 86 + 87 + 88 + def build_parser() -> argparse.ArgumentParser: 89 + parser = argparse.ArgumentParser( 90 + prog="sol setup", 91 + description="Set up solstone user-runtime artifacts and start the service.", 92 + ) 93 + parser.add_argument( 94 + "--journal", 95 + metavar="PATH", 96 + type=Path, 97 + default=None, 98 + help="journal directory to persist in ~/.config/solstone/config.toml", 99 + ) 100 + parser.add_argument( 101 + "--port", 102 + metavar="INT", 103 + type=int, 104 + default=5015, 105 + help="convey service port (default: 5015)", 106 + ) 107 + parser.add_argument( 108 + "--variant", 109 + choices=("auto", "cpu", "cuda", "coreml"), 110 + default="auto", 111 + help="Parakeet model/runtime variant passed to sol install-models (default: auto)", 112 + ) 113 + parser.add_argument( 114 + "-y", 115 + "--yes", 116 + "--non-interactive", 117 + dest="yes", 118 + action="store_true", 119 + help="run without prompts; fail with retry guidance when input is required", 120 + ) 121 + parser.add_argument( 122 + "--dry-run", 123 + action="store_true", 124 + help="print the resolved plan and commands without changing files or services", 125 + ) 126 + parser.add_argument( 127 + "--explain", 128 + action="store_true", 129 + help="print the setup steps and resolved defaults without running them", 130 + ) 131 + parser.add_argument( 132 + "--skip-models", 133 + action="store_true", 134 + help="skip local model installation", 135 + ) 136 + parser.add_argument( 137 + "--skip-skills", 138 + action="store_true", 139 + help="skip Claude Code skill installation", 140 + ) 141 + parser.add_argument( 142 + "--skip-service", 143 + action="store_true", 144 + help="skip service installation, start, and health check", 145 + ) 146 + parser.add_argument( 147 + "--accept-existing-journal", 148 + action="store_true", 149 + help="allow setup to use a non-empty existing journal directory", 150 + ) 151 + return parser 152 + 153 + 154 + def resolve_mode(args: argparse.Namespace) -> SetupMode: 155 + stdin_is_tty = sys.stdin.isatty() 156 + stdout_is_tty = sys.stdout.isatty() 157 + 158 + if args.explain: 159 + return SetupMode.EXPLAIN 160 + if args.dry_run: 161 + return SetupMode.DRY_RUN 162 + if args.yes: 163 + return SetupMode.NON_INTERACTIVE 164 + if stdin_is_tty and stdout_is_tty: 165 + return SetupMode.INTERACTIVE 166 + return SetupMode.NON_INTERACTIVE 167 + 168 + 169 + def resolve_context(args: argparse.Namespace, raw_argv: list[str]) -> SetupContext: 170 + mode = resolve_mode(args) 171 + project_root = Path(get_project_root()) 172 + is_source_checkout = (project_root / "pyproject.toml").exists() and ( 173 + project_root / ".git" 174 + ).exists() 175 + journal_path, journal_source = resolve_journal_path(args) 176 + cfg_path = config_path() 177 + manifest_path = journal_path / ".setup-state.json" 178 + port_supplied = arg_supplied(raw_argv, "--port") 179 + variant_supplied = arg_supplied(raw_argv, "--variant") 180 + 181 + args_resolved: dict[str, object] = { 182 + "journal": { 183 + "value": str(journal_path), 184 + "source": journal_source, 185 + }, 186 + "port": { 187 + "value": args.port, 188 + "source": "cli" if port_supplied else "default", 189 + }, 190 + "variant": { 191 + "value": args.variant, 192 + "source": "cli" if variant_supplied else "default", 193 + }, 194 + "yes": {"value": bool(args.yes), "source": "cli" if args.yes else "default"}, 195 + "dry_run": { 196 + "value": bool(args.dry_run), 197 + "source": "cli" if args.dry_run else "default", 198 + }, 199 + "explain": { 200 + "value": bool(args.explain), 201 + "source": "cli" if args.explain else "default", 202 + }, 203 + "skip_models": { 204 + "value": bool(args.skip_models), 205 + "source": "cli" if args.skip_models else "default", 206 + }, 207 + "skip_skills": { 208 + "value": bool(args.skip_skills), 209 + "source": "cli" if args.skip_skills else "default", 210 + }, 211 + "skip_service": { 212 + "value": bool(args.skip_service), 213 + "source": "cli" if args.skip_service else "default", 214 + }, 215 + "accept_existing_journal": { 216 + "value": bool(args.accept_existing_journal), 217 + "source": "cli" if args.accept_existing_journal else "default", 218 + }, 219 + "parakeet_onnx_variant_env": { 220 + "value": os.environ.get("PARAKEET_ONNX_VARIANT"), 221 + "source": "env", 222 + }, 223 + "is_source_checkout": { 224 + "value": is_source_checkout, 225 + "source": "detected", 226 + }, 227 + } 228 + 229 + return SetupContext( 230 + mode=mode, 231 + project_root=project_root, 232 + is_source_checkout=is_source_checkout, 233 + journal_path=journal_path, 234 + journal_source=journal_source, 235 + config_path=cfg_path, 236 + manifest_path=manifest_path, 237 + port=args.port, 238 + port_source="cli" if port_supplied else "default", 239 + port_supplied=port_supplied, 240 + variant=args.variant, 241 + variant_source="cli" if variant_supplied else "default", 242 + yes=bool(args.yes), 243 + skip_models=bool(args.skip_models), 244 + skip_skills=bool(args.skip_skills), 245 + skip_service=bool(args.skip_service), 246 + accept_existing_journal=bool(args.accept_existing_journal), 247 + stdin_is_tty=sys.stdin.isatty(), 248 + stdout_is_tty=sys.stdout.isatty(), 249 + args_resolved=args_resolved, 250 + doctor_advisories=[], 251 + ) 252 + 253 + 254 + def resolve_journal_path(args: argparse.Namespace) -> tuple[Path, str]: 255 + if args.journal is not None: 256 + return expand_path(args.journal), "cli" 257 + 258 + configured = read_user_config().get("journal", "").strip() 259 + if configured: 260 + return expand_path(configured), "config" 261 + 262 + return expand_path(default_journal()), "default" 263 + 264 + 265 + def arg_supplied(raw_argv: list[str], flag: str) -> bool: 266 + return flag in raw_argv or any(item.startswith(f"{flag}=") for item in raw_argv) 267 + 268 + 269 + def expand_path(path: str | Path) -> Path: 270 + return Path(path).expanduser().resolve() 271 + 272 + 273 + def utc_now() -> str: 274 + return ( 275 + datetime.now(timezone.utc) 276 + .replace(microsecond=0) 277 + .isoformat() 278 + .replace("+00:00", "Z") 279 + ) 280 + 281 + 282 + def absolute_string(path: Path) -> str: 283 + return str(path.expanduser().resolve()) 284 + 285 + 286 + def non_empty_journal(path: Path) -> bool: 287 + return path.is_dir() and ( 288 + (path / "config").is_dir() 289 + or any(path.glob("*.jsonl")) 290 + or any( 291 + p.is_dir() and p.name.isdigit() and len(p.name) == 8 for p in path.iterdir() 292 + ) 293 + ) 294 + 295 + 296 + def read_manifest(ctx: SetupContext) -> dict[str, Any] | None: 297 + try: 298 + return json.loads(ctx.manifest_path.read_text(encoding="utf-8")) 299 + except (FileNotFoundError, json.JSONDecodeError): 300 + return None 301 + 302 + 303 + def write_manifest(ctx: SetupContext, manifest: dict[str, Any]) -> None: 304 + try: 305 + ctx.manifest_path.parent.mkdir(parents=True, exist_ok=True) 306 + fd, tmp_name = tempfile.mkstemp( 307 + prefix=".tmp_setup_state", 308 + suffix=".json", 309 + dir=ctx.manifest_path.parent, 310 + ) 311 + tmp_path = Path(tmp_name) 312 + try: 313 + with os.fdopen(fd, "w", encoding="utf-8") as handle: 314 + json.dump(manifest, handle, indent=2) 315 + handle.write("\n") 316 + os.replace(tmp_path, ctx.manifest_path) 317 + except Exception: 318 + tmp_path.unlink(missing_ok=True) 319 + raise 320 + except Exception as exc: 321 + logging.warning("could not write setup manifest: %s", exc) 322 + 323 + 324 + def initial_manifest(ctx: SetupContext) -> dict[str, Any]: 325 + previous = read_manifest(ctx) 326 + if previous is not None: 327 + logging.debug("previous setup manifest found at %s", ctx.manifest_path) 328 + return { 329 + "schema_version": MANIFEST_SCHEMA_VERSION, 330 + "started_at": utc_now(), 331 + "completed_at": None, 332 + "mode": ctx.mode.value, 333 + "args_resolved": ctx.args_resolved, 334 + "steps": [], 335 + } 336 + 337 + 338 + def append_step(manifest: dict[str, Any], result: StepResult) -> None: 339 + steps = manifest.setdefault("steps", []) 340 + steps.append(asdict(result)) 341 + 342 + 343 + def step_result( 344 + name: str, 345 + status: StepStatus, 346 + paths: list[Path | str], 347 + started_at: str, 348 + error: dict[str, object] | None = None, 349 + ) -> StepResult: 350 + return StepResult( 351 + name=name, 352 + status=status, 353 + paths=[absolute_string(Path(path)) for path in paths], 354 + started_at=started_at, 355 + finished_at=utc_now(), 356 + error=error, 357 + ) 358 + 359 + 360 + def print_step_header( 361 + step_index: int, label: str, command: list[str] | None = None 362 + ) -> None: 363 + if command: 364 + print( 365 + f"[step {step_index}/{TOTAL_STEPS}] running {label}: {format_command(command)}" 366 + ) 367 + else: 368 + print(f"[step {step_index}/{TOTAL_STEPS}] running {label}...") 369 + 370 + 371 + def print_step_skipped(step_index: int, name: str, reason: str) -> None: 372 + print(f"[step {step_index}/{TOTAL_STEPS}] skipped {name}: {reason}") 373 + 374 + 375 + def format_command(command: list[str]) -> str: 376 + return " ".join(command) 377 + 378 + 379 + def run_inherited(command: list[str]) -> int: 380 + result = subprocess.run(command, stdout=None, stderr=None, check=False) 381 + return int(result.returncode) 382 + 383 + 384 + def doctor_command(ctx: SetupContext) -> list[str]: 385 + return [ 386 + sys.executable, 387 + "-m", 388 + "think.sol_cli", 389 + "doctor", 390 + "--json", 391 + "--port", 392 + str(ctx.port), 393 + ] 394 + 395 + 396 + def install_models_command(ctx: SetupContext) -> list[str]: 397 + return [ 398 + sys.executable, 399 + "-m", 400 + "think.sol_cli", 401 + "install-models", 402 + "--variant", 403 + ctx.variant, 404 + ] 405 + 406 + 407 + def skills_command() -> list[str]: 408 + return [ 409 + sys.executable, 410 + "-m", 411 + "think.sol_cli", 412 + "skills", 413 + "install", 414 + "--agent", 415 + "claude", 416 + ] 417 + 418 + 419 + def wrapper_command() -> list[str]: 420 + return [sys.executable, "-m", "think.install_guard", "install"] 421 + 422 + 423 + def service_install_command(ctx: SetupContext) -> list[str]: 424 + return [ 425 + sys.executable, 426 + "-m", 427 + "think.sol_cli", 428 + "service", 429 + "install", 430 + "--port", 431 + str(ctx.port), 432 + ] 433 + 434 + 435 + def step_doctor(ctx: SetupContext, step_index: int) -> StepResult: 436 + started_at = utc_now() 437 + command = doctor_command(ctx) 438 + print_step_header(step_index, "doctor", command) 439 + result = subprocess.run( 440 + command, 441 + capture_output=True, 442 + text=True, 443 + check=False, 444 + ) 445 + if result.returncode != 0: 446 + if result.stdout: 447 + print(result.stdout, end="" if result.stdout.endswith("\n") else "\n") 448 + if result.stderr: 449 + print( 450 + result.stderr, 451 + end="" if result.stderr.endswith("\n") else "\n", 452 + file=sys.stderr, 453 + ) 454 + return step_result( 455 + "doctor", 456 + "failed", 457 + [], 458 + started_at, 459 + {"message": "doctor blocker failed", "exit_code": int(result.returncode)}, 460 + ) 461 + 462 + try: 463 + payload = json.loads(result.stdout) 464 + except json.JSONDecodeError as exc: 465 + return step_result( 466 + "doctor", 467 + "failed", 468 + [], 469 + started_at, 470 + { 471 + "message": f"doctor JSON parse failed: {exc}", 472 + "exit_code": 1, 473 + }, 474 + ) 475 + 476 + checks = payload.get("checks", []) 477 + if isinstance(checks, list): 478 + ctx.doctor_advisories[:] = [ 479 + check 480 + for check in checks 481 + if isinstance(check, dict) 482 + and check.get("severity") == "advisory" 483 + and check.get("status") in ("warn", "fail") 484 + ] 485 + maybe_dead_end_on_port(ctx) 486 + print(f"[step {step_index}/{TOTAL_STEPS}] doctor passed") 487 + return step_result("doctor", "ok", [], started_at) 488 + 489 + 490 + def maybe_dead_end_on_port(ctx: SetupContext) -> None: 491 + if ( 492 + ctx.skip_service 493 + or ctx.port_supplied 494 + or ctx.mode is not SetupMode.NON_INTERACTIVE 495 + ): 496 + return 497 + for advisory in ctx.doctor_advisories: 498 + if advisory.get("name") == "port_5015_free": 499 + dead_end_port_in_use(ctx) 500 + detail = advisory.get("detail") 501 + if isinstance(detail, str) and f"port {ctx.port}" in detail: 502 + dead_end_port_in_use(ctx) 503 + 504 + 505 + def step_journal(ctx: SetupContext, step_index: int) -> StepResult: 506 + started_at = utc_now() 507 + print_step_header(step_index, "journal config") 508 + if non_empty_journal(ctx.journal_path) and not ctx.accept_existing_journal: 509 + if ctx.mode is SetupMode.NON_INTERACTIVE: 510 + dead_end_existing_journal(ctx) 511 + if not prompt_accept_existing_journal(ctx.journal_path): 512 + raise SetupDeadEnd("setup aborted by user", 2) 513 + 514 + persisted = read_user_config().get("journal", "").strip() 515 + persisted_matches = bool(persisted) and expand_path(persisted) == ctx.journal_path 516 + if not persisted_matches: 517 + write_user_config(journal=str(ctx.journal_path)) 518 + print(f"[step {step_index}/{TOTAL_STEPS}] wrote {ctx.config_path}") 519 + else: 520 + print(f"[step {step_index}/{TOTAL_STEPS}] journal config already current") 521 + ctx.journal_path.mkdir(parents=True, exist_ok=True) 522 + return step_result( 523 + "journal", 524 + "ok", 525 + [ctx.config_path, ctx.journal_path], 526 + started_at, 527 + ) 528 + 529 + 530 + def prompt_accept_existing_journal(path: Path) -> bool: 531 + answer = input(f"Use existing journal at {path}? [y/N]: ").strip().lower() 532 + return answer in {"y", "yes"} 533 + 534 + 535 + def linux_model_sentinel() -> Path: 536 + return Path.home() / ".cache" / "huggingface" / "hub" / ".solstone-install-complete" 537 + 538 + 539 + def mac_model_sentinel() -> Path: 540 + return ( 541 + Path.home() 542 + / "Library" 543 + / "Application Support" 544 + / "solstone" 545 + / "parakeet" 546 + / "models" 547 + / ".install-complete" 548 + ) 549 + 550 + 551 + def model_paths() -> list[Path]: 552 + if sys.platform.startswith("linux"): 553 + return [linux_model_sentinel()] 554 + if sys.platform == "darwin": 555 + return [mac_model_sentinel()] 556 + return [] 557 + 558 + 559 + def step_install_models(ctx: SetupContext, step_index: int) -> StepResult: 560 + started_at = utc_now() 561 + if ctx.skip_models: 562 + print_step_skipped(step_index, "install_models", "--skip-models") 563 + return step_result("install_models", "skipped", [], started_at) 564 + command = install_models_command(ctx) 565 + print_step_header(step_index, "install-models", command) 566 + rc = run_inherited(command) 567 + if rc != 0: 568 + return step_result( 569 + "install_models", 570 + "failed", 571 + model_paths(), 572 + started_at, 573 + {"message": "install-models failed", "exit_code": rc}, 574 + ) 575 + return step_result("install_models", "ok", model_paths(), started_at) 576 + 577 + 578 + def step_skills(ctx: SetupContext, step_index: int) -> StepResult: 579 + started_at = utc_now() 580 + claude_dir = Path.home() / ".claude" 581 + skill_path = claude_dir / "skills" / "solstone" / "SKILL.md" 582 + if ctx.skip_skills: 583 + print_step_skipped(step_index, "skills", "--skip-skills") 584 + return step_result("skills", "skipped", [], started_at) 585 + if not claude_dir.exists(): 586 + reason = f"Claude Code config not found at {claude_dir}" 587 + print_step_skipped(step_index, "skills", reason) 588 + return step_result("skills", "skipped", [], started_at) 589 + command = skills_command() 590 + print_step_header(step_index, "skills", command) 591 + rc = run_inherited(command) 592 + if rc != 0: 593 + return step_result( 594 + "skills", 595 + "failed", 596 + [skill_path], 597 + started_at, 598 + {"message": "skills install failed", "exit_code": rc}, 599 + ) 600 + return step_result("skills", "ok", [skill_path], started_at) 601 + 602 + 603 + def step_wrapper(ctx: SetupContext, step_index: int) -> StepResult: 604 + started_at = utc_now() 605 + wrapper_path = Path.home() / ".local" / "bin" / "sol" 606 + if not ctx.is_source_checkout: 607 + print_step_skipped(step_index, "wrapper", "packaged install") 608 + return step_result("wrapper", "skipped", [], started_at) 609 + command = wrapper_command() 610 + print_step_header(step_index, "wrapper", command) 611 + rc = run_inherited(command) 612 + if rc != 0: 613 + return step_result( 614 + "wrapper", 615 + "failed", 616 + [wrapper_path], 617 + started_at, 618 + {"message": "wrapper install failed", "exit_code": rc}, 619 + ) 620 + return step_result("wrapper", "ok", [wrapper_path], started_at) 621 + 622 + 623 + def service_artifact_path() -> Path | None: 624 + if sys.platform == "darwin": 625 + return Path.home() / "Library" / "LaunchAgents" / "org.solpbc.solstone.plist" 626 + if sys.platform.startswith("linux"): 627 + return Path.home() / ".config" / "systemd" / "user" / "solstone.service" 628 + return None 629 + 630 + 631 + def step_service(ctx: SetupContext, step_index: int) -> StepResult: 632 + started_at = utc_now() 633 + artifact = service_artifact_path() 634 + paths = [artifact] if artifact is not None else [] 635 + if ctx.skip_service: 636 + print_step_skipped(step_index, "service", "--skip-service") 637 + return step_result("service", "skipped", [], started_at) 638 + if not ctx.is_source_checkout: 639 + ctx.service_skipped_packaged = True 640 + reason = ( 641 + "packaged-install service support is not implemented in v1. " 642 + "Setup completed the journal, model, and skill steps; run sol setup " 643 + "again from a source checkout to install the background service." 644 + ) 645 + print_step_skipped(step_index, "service", reason) 646 + return step_result("service", "skipped", [], started_at) 647 + 648 + command = service_install_command(ctx) 649 + print_step_header(step_index, "service install", command) 650 + rc = run_inherited(command) 651 + if rc != 0: 652 + return step_result( 653 + "service", 654 + "failed", 655 + paths, 656 + started_at, 657 + {"message": "service install failed", "exit_code": rc}, 658 + ) 659 + 660 + from think.service import _up 661 + 662 + print(f"[step {step_index}/{TOTAL_STEPS}] running service up...") 663 + up_rc = int(_up(port=ctx.port)) 664 + if up_rc != 0: 665 + return step_result( 666 + "service", 667 + "failed", 668 + paths, 669 + started_at, 670 + {"message": "service up failed", "exit_code": up_rc}, 671 + ) 672 + 673 + from think.health_cli import health_check 674 + 675 + print(f"[step {step_index}/{TOTAL_STEPS}] waiting for health...") 676 + for attempt in range(1, HEALTH_ATTEMPTS + 1): 677 + if health_check() == 0: 678 + return step_result("service", "ok", paths, started_at) 679 + if attempt < HEALTH_ATTEMPTS: 680 + time.sleep(HEALTH_SLEEP_SECONDS) 681 + return step_result( 682 + "service", 683 + "failed", 684 + paths, 685 + started_at, 686 + {"message": "service readiness timeout after 20s", "exit_code": 1}, 687 + ) 688 + 689 + 690 + def dead_end_existing_journal(ctx: SetupContext) -> None: 691 + message = "\n".join( 692 + [ 693 + ( 694 + "sol setup: cannot proceed in non-interactive mode - " 695 + f"{ctx.journal_path} already contains journal data." 696 + ), 697 + "Setup will not auto-claim an existing journal.", 698 + "", 699 + "Retry with one of:", 700 + " sol setup --accept-existing-journal", 701 + " sol setup --journal /path/to/new-journal --accept-existing-journal", 702 + "", 703 + "Interactive escape:", 704 + " sol setup", 705 + "", 706 + "Run 'sol setup --explain' for full step list.", 707 + ] 708 + ) 709 + raise SetupDeadEnd(message, 2) 710 + 711 + 712 + def dead_end_port_in_use(ctx: SetupContext) -> None: 713 + message = "\n".join( 714 + [ 715 + ( 716 + "sol setup: cannot proceed in non-interactive mode - " 717 + f"port {ctx.port} is already in use." 718 + ), 719 + "Setup will not choose a different service port silently.", 720 + "", 721 + "Retry with one of:", 722 + " sol setup --port <port>", 723 + " sol setup --skip-service", 724 + "", 725 + "Interactive escape:", 726 + " sol setup", 727 + "", 728 + "Run 'sol setup --explain' for full step list.", 729 + ] 730 + ) 731 + raise SetupDeadEnd(message, 2) 732 + 733 + 734 + def print_plan(ctx: SetupContext, *, dry_run: bool) -> None: 735 + heading = "setup dry-run" if dry_run else "setup plan" 736 + print(f"{heading}:") 737 + print(f" mode: {ctx.mode.value}") 738 + print(f" journal: {ctx.journal_path} ({ctx.journal_source})") 739 + print(f" port: {ctx.port} ({ctx.port_source})") 740 + print(f" variant: {ctx.variant} ({ctx.variant_source})") 741 + print(f" source checkout: {ctx.is_source_checkout}") 742 + print() 743 + print("[step 1/6] doctor") 744 + print(f" would run: {format_command(doctor_command(ctx))}") 745 + print("[step 2/6] journal") 746 + print(f" would write: {ctx.config_path}") 747 + print(f" would use journal: {ctx.journal_path}") 748 + print("[step 3/6] install_models") 749 + if ctx.skip_models: 750 + print(" skipped: --skip-models") 751 + else: 752 + print(f" would run: {format_command(install_models_command(ctx))}") 753 + print("[step 4/6] skills") 754 + if ctx.skip_skills: 755 + print(" skipped: --skip-skills") 756 + else: 757 + print(f" would run: {format_command(skills_command())}") 758 + print("[step 5/6] wrapper") 759 + if not ctx.is_source_checkout: 760 + print(" skipped: packaged install") 761 + else: 762 + print(f" would run: {format_command(wrapper_command())}") 763 + print("[step 6/6] service") 764 + if ctx.skip_service: 765 + print(" skipped: --skip-service") 766 + elif not ctx.is_source_checkout: 767 + print(" skipped: packaged-install service support is not implemented in v1") 768 + else: 769 + print(f" would run: {format_command(service_install_command(ctx))}") 770 + print(f" would call: think.service._up(port={ctx.port})") 771 + print( 772 + f" would call: think.health_cli.health_check() up to {HEALTH_ATTEMPTS} times" 773 + ) 774 + 775 + 776 + def print_failure(result: StepResult) -> None: 777 + error = result.error or {} 778 + message = error.get("message", "step failed") 779 + print(f"sol setup: {result.name} failed: {message}", file=sys.stderr) 780 + 781 + 782 + def print_success_summary(ctx: SetupContext, manifest: dict[str, Any]) -> None: 783 + print() 784 + print("solstone is set up.") 785 + print() 786 + print("artifacts:") 787 + paths = artifact_paths(ctx, manifest) 788 + if paths: 789 + for path in paths: 790 + print(f" {path}") 791 + else: 792 + print(" none") 793 + if ctx.service_skipped_packaged: 794 + print( 795 + " service: skipped (packaged-install service support is not implemented in v1)" 796 + ) 797 + print() 798 + if ctx.doctor_advisories: 799 + print("advisories from doctor:") 800 + for advisory in ctx.doctor_advisories: 801 + detail = advisory.get("detail") 802 + if detail: 803 + print(f" - {detail}") 804 + else: 805 + print("advisories from doctor: none") 806 + print() 807 + if not ctx.skip_service and ctx.is_source_checkout: 808 + print(f"solstone is running at http://localhost:{ctx.port}") 809 + print() 810 + print("next: run 'sol observer install' to start observing.") 811 + 812 + 813 + def artifact_paths(ctx: SetupContext, manifest: dict[str, Any]) -> list[str]: 814 + seen: set[str] = set() 815 + paths: list[str] = [] 816 + for step in manifest.get("steps", []): 817 + if not isinstance(step, dict): 818 + continue 819 + for item in step.get("paths", []): 820 + if not isinstance(item, str) or item in seen: 821 + continue 822 + seen.add(item) 823 + paths.append(item) 824 + manifest_path = absolute_string(ctx.manifest_path) 825 + if ( 826 + ctx.mode not in (SetupMode.DRY_RUN, SetupMode.EXPLAIN) 827 + and manifest_path not in seen 828 + ): 829 + paths.append(manifest_path) 830 + return paths 831 + 832 + 833 + def run_setup(ctx: SetupContext) -> int: 834 + if ctx.mode is SetupMode.EXPLAIN: 835 + print_plan(ctx, dry_run=False) 836 + return 0 837 + if ctx.mode is SetupMode.DRY_RUN: 838 + print_plan(ctx, dry_run=True) 839 + return 0 840 + 841 + manifest = initial_manifest(ctx) 842 + steps = [ 843 + step_doctor, 844 + step_journal, 845 + step_install_models, 846 + step_skills, 847 + step_wrapper, 848 + step_service, 849 + ] 850 + for index, step in enumerate(steps, start=1): 851 + result = step(ctx, index) 852 + append_step(manifest, result) 853 + write_manifest(ctx, manifest) 854 + if result.status == "failed": 855 + print_failure(result) 856 + error = result.error or {} 857 + return int(error.get("exit_code", 1)) 858 + 859 + manifest["completed_at"] = utc_now() 860 + write_manifest(ctx, manifest) 861 + print_success_summary(ctx, manifest) 862 + return 0 863 + 864 + 865 + def main(argv: list[str] | None = None) -> int: 866 + raw_argv = list(argv) if argv is not None else sys.argv[1:] 867 + parser = build_parser() 868 + args = parser.parse_args(raw_argv) 869 + ctx = resolve_context(args, raw_argv) 870 + try: 871 + return run_setup(ctx) 872 + except SetupDeadEnd as exc: 873 + print(exc.message, file=sys.stderr) 874 + return exc.exit_code 875 + 876 + 877 + if __name__ == "__main__": 878 + sys.exit(main())
+2 -1
think/sol_cli.py
··· 76 76 "restart-convey": "convey.restart", 77 77 "maint": "convey.maint_cli", 78 78 "service": "think.service", 79 + "setup": "think.setup", 79 80 } 80 81 81 82 # ============================================================================= ··· 128 129 "restart-convey", 129 130 "maint", 130 131 ], 131 - "Setup": ["install-models"], 132 + "Setup": ["setup", "install-models"], 132 133 "Specialized tools": [ 133 134 "password", 134 135 "config",