diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 00000000..9cc1eb92 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,92 @@ +# Bootstrap Wizard + +Interactive, AI-guided setup for Turnstone deployments. Instead of manually +editing `.env` files and reading deployment docs, the wizard walks you through +every decision conversationally and generates all the config files for you. + +## Quick Start + +```bash +turnstone-bootstrap +``` + +That's it — no flags, no arguments. The wizard prompts for everything. + +## How It Works + +1. **Pick a model** — Choose OpenAI, Anthropic, or a local/vLLM endpoint to + power the wizard. Local endpoints auto-detect available models. +2. **Answer questions** — The AI walks you through deployment mode, LLM + provider, database, authentication, ports, and optional features. +3. **Review generated files** — Each file is previewed before writing. You + confirm or reject every write. +4. **Start the stack** — The wizard prints the exact `docker compose` command + and a `setup.sh` script to create your first admin user, roles, and policies. + +## What Gets Generated + +| File | Purpose | +|------|---------| +| `.env` | All environment variables for `compose.yaml` | +| `setup.sh` | Post-start script: creates admin user, roles, tool policies, prompt templates via the API | +| `docker-compose.override.yaml` | Only if customizations beyond env vars are needed | + +## Requirements + +- **Python 3.11+** with turnstone installed (`pip install turnstone`) +- **An LLM API key** — for the wizard itself (OpenAI, Anthropic, or a local + model). This can differ from the LLM your deployment will use. +- **Docker & Docker Compose** — needed to run the stack. The wizard detects + whether Docker is installed and gives platform-specific install instructions + if it's missing. You can still generate config files without Docker. + +## Deployment Modes + +The wizard supports two deployment modes: + +- **Single-node production** (`docker compose --profile production up`) — + 1 server + bridge + console + PostgreSQL + Redis. Good for most use cases. +- **Multi-node cluster** (`docker compose --profile cluster up`) — + 10-node server/bridge fleet + PostgreSQL + Redis. For high-throughput or + HA deployments. + +## Example Session + +``` +$ turnstone-bootstrap + + Turnstone Bootstrap Wizard v0.5.4 + ──────────────────────────────────────────────── + + Which provider for this wizard? + [1] OpenAI + [2] Anthropic + [3] OpenAI-compatible (local/vLLM) + + > 3 + + Base URL [http://localhost:8000/v1]: + API key (press Enter for 'none'): + + Querying http://localhost:8000/v1 for available models... + Found model: Qwen/Qwen3-32B + + Connected to Qwen/Qwen3-32B. Handing off to AI assistant... + +> (AI walks you through the rest interactively) +``` + +## Tips + +- **Re-run safely** — running the wizard again detects your existing `.env` + and offers to update it rather than overwriting. +- **Duplicate writes are skipped** — if the LLM tries to write the same file + twice with identical content, it's silently ignored. +- **Type `quit` to exit** at any time during the conversation. +- **Ctrl+C** is handled gracefully — press once to interrupt, twice to exit. + +## See Also + +- [Docker Deployment](docker.md) — manual compose setup and profiles +- [Security](security.md) — auth architecture and token types +- [Governance](governance.md) — roles, policies, and templates diff --git a/pyproject.toml b/pyproject.toml index 326fcf42..9780973a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ turnstone-console = "turnstone.console.server:main" turnstone-sim = "turnstone.sim.cli:main" turnstone-admin = "turnstone.admin:main" turnstone-channel = "turnstone.channels.cli:main" +turnstone-bootstrap = "turnstone.bootstrap:main" [tool.hatch.build.targets.wheel] include = [ diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 00000000..45c97051 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,630 @@ +"""Tests for the bootstrap wizard module.""" + +from __future__ import annotations + +import os +import socket +from pathlib import Path +from unittest.mock import MagicMock, patch + +from turnstone.bootstrap import ( + SYSTEM_PROMPT, + TOOLS, + _BootstrapLLM, + _FinishError, + _mask_secrets, + _tool_check_docker, + _tool_check_port, + _tool_finish, + _tool_generate_secret, + _tool_read_file, + _tool_validate_api_key, + _tool_write_file, + execute_tool, +) + +# --------------------------------------------------------------------------- +# Tool function tests +# --------------------------------------------------------------------------- + + +class TestReadFile: + def test_existing_file(self, tmp_path: Path) -> None: + f = tmp_path / "test.txt" + f.write_text("hello world") + result = _tool_read_file(tmp_path, {"path": "test.txt"}) + assert result == "hello world" + + def test_missing_file(self, tmp_path: Path) -> None: + result = _tool_read_file(tmp_path, {"path": "nope.txt"}) + assert "Error: file not found" in result + + def test_nested_path(self, tmp_path: Path) -> None: + sub = tmp_path / "sub" + sub.mkdir() + f = sub / "nested.txt" + f.write_text("nested content") + result = _tool_read_file(tmp_path, {"path": "sub/nested.txt"}) + assert result == "nested content" + + def test_path_traversal_blocked(self, tmp_path: Path) -> None: + result = _tool_read_file(tmp_path, {"path": "../../etc/passwd"}) + assert "escapes project directory" in result + + def test_absolute_path_blocked(self, tmp_path: Path) -> None: + result = _tool_read_file(tmp_path, {"path": "/etc/passwd"}) + assert "escapes project directory" in result + + +class TestWriteFile: + def test_write_confirmed(self, tmp_path: Path) -> None: + with patch("builtins.input", return_value="y"): + result = _tool_write_file(tmp_path, {"path": "out.txt", "content": "data\n"}) + assert "written successfully" in result + assert (tmp_path / "out.txt").read_text() == "data\n" + + def test_write_declined(self, tmp_path: Path) -> None: + with patch("builtins.input", return_value="n"): + result = _tool_write_file(tmp_path, {"path": "out.txt", "content": "data\n"}) + assert "declined" in result + assert not (tmp_path / "out.txt").exists() + + def test_write_creates_parent_dirs(self, tmp_path: Path) -> None: + with patch("builtins.input", return_value="y"): + result = _tool_write_file(tmp_path, {"path": "a/b/c.txt", "content": "deep\n"}) + assert "written successfully" in result + assert (tmp_path / "a" / "b" / "c.txt").read_text() == "deep\n" + + def test_sh_files_are_executable(self, tmp_path: Path) -> None: + with patch("builtins.input", return_value="y"): + _tool_write_file(tmp_path, {"path": "setup.sh", "content": "#!/bin/bash\n"}) + mode = (tmp_path / "setup.sh").stat().st_mode + assert mode & 0o110 # user + group executable, not world + + def test_path_traversal_blocked(self, tmp_path: Path) -> None: + result = _tool_write_file(tmp_path, {"path": "../../escape.txt", "content": "bad\n"}) + assert "escapes project directory" in result + + def test_default_enter_confirms(self, tmp_path: Path) -> None: + with patch("builtins.input", return_value=""): + result = _tool_write_file(tmp_path, {"path": "ok.txt", "content": "ok\n"}) + assert "written successfully" in result + + def test_duplicate_write_skipped(self, tmp_path: Path) -> None: + (tmp_path / "dup.txt").write_text("same\n") + result = _tool_write_file(tmp_path, {"path": "dup.txt", "content": "same\n"}) + assert "already exists" in result + + def test_different_content_still_prompts(self, tmp_path: Path) -> None: + (tmp_path / "changed.txt").write_text("old\n") + with patch("builtins.input", return_value="y"): + result = _tool_write_file(tmp_path, {"path": "changed.txt", "content": "new\n"}) + assert "written successfully" in result + assert (tmp_path / "changed.txt").read_text() == "new\n" + + +class TestGenerateSecret: + def test_default_length(self) -> None: + secret = _tool_generate_secret({}) + assert len(secret) == 64 # 32 bytes -> 64 hex chars + + def test_custom_length(self) -> None: + secret = _tool_generate_secret({"length": 16}) + assert len(secret) == 32 + + def test_uniqueness(self) -> None: + s1 = _tool_generate_secret({}) + s2 = _tool_generate_secret({}) + assert s1 != s2 + + def test_invalid_length_fallback(self) -> None: + secret = _tool_generate_secret({"length": -1}) + assert len(secret) == 64 # falls back to 32 bytes + + def test_excessive_length_capped(self) -> None: + secret = _tool_generate_secret({"length": 99999}) + assert len(secret) == 64 # falls back to 32 bytes + + +class TestCheckPort: + def test_available_port(self) -> None: + # Pick a random high port that's likely free + result = _tool_check_port({"port": 59123}) + assert "AVAILABLE" in result or "IN USE" in result + + def test_in_use_port(self) -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.listen(1) + result = _tool_check_port({"port": port}) + assert "IN USE" in result + + def test_invalid_port(self) -> None: + result = _tool_check_port({"port": -1}) + assert "Error" in result + + def test_port_zero(self) -> None: + result = _tool_check_port({"port": 0}) + assert "Error" in result + + +class TestCheckDocker: + def test_docker_installed(self) -> None: + mock_docker = MagicMock() + mock_docker.returncode = 0 + mock_docker.stdout = "24.0.7" + + mock_compose = MagicMock() + mock_compose.returncode = 0 + mock_compose.stdout = "2.24.5" + + with patch("subprocess.run", side_effect=[mock_docker, mock_compose]): + result = _tool_check_docker({}) + assert "Docker: installed" in result + assert "Docker Compose: installed" in result + + def test_docker_not_installed(self) -> None: + with patch("subprocess.run", side_effect=FileNotFoundError): + result = _tool_check_docker({}) + assert "NOT installed" in result or "NOT available" in result + + def test_docker_daemon_not_running(self) -> None: + mock_docker = MagicMock() + mock_docker.returncode = 1 + mock_docker.stderr = "Cannot connect to the Docker daemon" + + mock_compose = MagicMock() + mock_compose.returncode = 1 + + with patch("subprocess.run", side_effect=[mock_docker, mock_compose]): + result = _tool_check_docker({}) + assert "NOT running" in result + + +class TestValidateApiKey: + def test_openai_success(self) -> None: + mock_client = MagicMock() + mock_client.models.list.return_value = [] + with patch("openai.OpenAI", return_value=mock_client): + result = _tool_validate_api_key({"provider": "openai", "api_key": "sk-test"}) + assert "Success" in result + + def test_openai_failure(self) -> None: + with patch("openai.OpenAI") as mock_cls: + mock_cls.return_value.models.list.side_effect = Exception("Invalid key") + result = _tool_validate_api_key({"provider": "openai", "api_key": "bad"}) + assert "Failed" in result + + def test_unknown_provider(self) -> None: + result = _tool_validate_api_key({"provider": "unknown", "api_key": "x"}) + assert "unknown" in result + + +class TestExecuteTool: + def test_unknown_tool(self, tmp_path: Path) -> None: + result = execute_tool("nonexistent", {}, tmp_path) + assert "unknown tool" in result + + def test_dispatches_correctly(self, tmp_path: Path) -> None: + f = tmp_path / "hello.txt" + f.write_text("hi") + result = execute_tool("read_file", {"path": "hello.txt"}, tmp_path) + assert result == "hi" + + def test_finish_raises(self, tmp_path: Path) -> None: + import pytest + + with pytest.raises(_FinishError, match="All done"): + execute_tool("finish", {"summary": "All done"}, tmp_path) + + +class TestFinishTool: + def test_raises_with_summary(self) -> None: + import pytest + + with pytest.raises(_FinishError) as exc_info: + _tool_finish({"summary": "Configured production deployment."}) + assert exc_info.value.summary == "Configured production deployment." + + def test_default_summary(self) -> None: + import pytest + + with pytest.raises(_FinishError) as exc_info: + _tool_finish({}) + assert exc_info.value.summary == "Setup complete." + + +# --------------------------------------------------------------------------- +# Secret masking tests +# --------------------------------------------------------------------------- + + +class TestMaskSecrets: + def test_masks_api_key(self) -> None: + text = "OPENAI_API_KEY=sk-1234567890abcdef" + result = _mask_secrets(text) + assert "sk-1" in result + assert "cdef" in result + assert "1234567890abcde" not in result + + def test_preserves_comments(self) -> None: + text = "# OPENAI_API_KEY=sk-1234567890abcdef" + result = _mask_secrets(text) + assert result == text + + def test_preserves_short_values(self) -> None: + text = "TOKEN=short" + result = _mask_secrets(text) + assert result == text + + def test_preserves_non_sensitive(self) -> None: + text = "MODEL=gpt-5.4" + result = _mask_secrets(text) + assert result == text + + +# --------------------------------------------------------------------------- +# Message conversion tests (Anthropic) +# --------------------------------------------------------------------------- + + +class TestAnthropicConversion: + """Test the Anthropic message/tool conversion inside _BootstrapLLM.""" + + def _make_llm(self) -> _BootstrapLLM: + return _BootstrapLLM("anthropic", MagicMock(), "test-model") + + def test_tool_format_conversion(self) -> None: + """OpenAI tool format should convert to Anthropic format.""" + llm = self._make_llm() + # The conversion happens inside _complete_anthropic; we test indirectly + # by checking the tools passed to the mock client + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="hello")] + mock_response.stop_reason = "end_turn" + llm.client.messages.create.return_value = mock_response + + llm.complete( + [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}], + TOOLS[:1], # Just read_file + ) + + call_kwargs = llm.client.messages.create.call_args[1] + api_tools = call_kwargs["tools"] + assert len(api_tools) == 1 + assert api_tools[0]["name"] == "read_file" + assert "input_schema" in api_tools[0] + assert "description" in api_tools[0] + + def test_system_message_extraction(self) -> None: + """System message should be extracted to system parameter.""" + llm = self._make_llm() + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="ok")] + mock_response.stop_reason = "end_turn" + llm.client.messages.create.return_value = mock_response + + llm.complete( + [{"role": "system", "content": "test system"}, {"role": "user", "content": "hi"}], + [], + ) + + call_kwargs = llm.client.messages.create.call_args[1] + assert call_kwargs["system"] == "test system" + # System should NOT appear in messages + for msg in call_kwargs["messages"]: + assert msg["role"] != "system" + + def test_tool_result_conversion(self) -> None: + """OpenAI tool result messages should convert to Anthropic format.""" + llm = self._make_llm() + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="got it")] + mock_response.stop_reason = "end_turn" + llm.client.messages.create.return_value = mock_response + + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "tc_1", + "type": "function", + "function": {"name": "check_docker", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "tc_1", + "content": "Docker: installed", + }, + ] + llm.complete(messages, TOOLS) + + call_kwargs = llm.client.messages.create.call_args[1] + api_messages = call_kwargs["messages"] + + # Find the tool_result message + tool_result_found = False + for msg in api_messages: + if msg["role"] == "user" and isinstance(msg.get("content"), list): + for block in msg["content"]: + if isinstance(block, dict) and block.get("type") == "tool_result": + assert block["tool_use_id"] == "tc_1" + assert block["content"] == "Docker: installed" + tool_result_found = True + assert tool_result_found + + def test_tool_use_blocks_in_assistant(self) -> None: + """Assistant messages with tool_calls should convert to content blocks.""" + llm = self._make_llm() + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="ok")] + mock_response.stop_reason = "end_turn" + llm.client.messages.create.return_value = mock_response + + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "Let me check", + "tool_calls": [ + { + "id": "tc_1", + "type": "function", + "function": {"name": "check_docker", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "tc_1", "content": "ok"}, + ] + llm.complete(messages, TOOLS) + + call_kwargs = llm.client.messages.create.call_args[1] + api_messages = call_kwargs["messages"] + + # First message should be user "hi" + assert api_messages[0]["role"] == "user" + # Second should be assistant with content blocks + assistant_msg = api_messages[1] + assert assistant_msg["role"] == "assistant" + assert isinstance(assistant_msg["content"], list) + # Should have text block + tool_use block + types = [b["type"] for b in assistant_msg["content"]] + assert "text" in types + assert "tool_use" in types + + +class TestOpenAICompletion: + """Test the OpenAI path of _BootstrapLLM.""" + + def test_text_response(self) -> None: + llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4") + mock_choice = MagicMock() + mock_choice.message.content = "Hello!" + mock_choice.message.tool_calls = None + mock_choice.finish_reason = "stop" + llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice]) + + content, tool_calls, reason = llm.complete([{"role": "user", "content": "hi"}], TOOLS) + assert content == "Hello!" + assert tool_calls is None + assert reason == "stop" + + def test_tool_call_response(self) -> None: + llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4") + + mock_tc = MagicMock() + mock_tc.id = "call_123" + mock_tc.function.name = "check_docker" + mock_tc.function.arguments = "{}" + + mock_choice = MagicMock() + mock_choice.message.content = "" + mock_choice.message.tool_calls = [mock_tc] + mock_choice.finish_reason = "tool_calls" + llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice]) + + content, tool_calls, reason = llm.complete( + [{"role": "user", "content": "check docker"}], TOOLS + ) + assert tool_calls is not None + assert len(tool_calls) == 1 + assert tool_calls[0]["function"]["name"] == "check_docker" + assert tool_calls[0]["id"] == "call_123" + + def test_no_content(self) -> None: + llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4") + mock_choice = MagicMock() + mock_choice.message.content = None + mock_choice.message.tool_calls = None + mock_choice.finish_reason = "stop" + llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice]) + + content, tool_calls, reason = llm.complete([{"role": "user", "content": "hi"}], []) + assert content == "" + assert tool_calls is None + + +# --------------------------------------------------------------------------- +# Conversation loop tests +# --------------------------------------------------------------------------- + + +class TestConversationLoop: + def test_quit_exits(self) -> None: + """User typing 'quit' should exit the loop.""" + llm = MagicMock(spec=_BootstrapLLM) + llm.complete.return_value = ("What would you like?", None, "stop") + + with patch("builtins.input", return_value="quit"): + from turnstone.bootstrap import _run_conversation + + _run_conversation(llm, Path("/tmp")) + + def test_tool_calls_executed(self, tmp_path: Path) -> None: + """Tool calls should be executed and results fed back.""" + llm = MagicMock(spec=_BootstrapLLM) + # First call: LLM returns a tool call + llm.complete.side_effect = [ + ( + "", + [ + { + "id": "tc_1", + "type": "function", + "function": {"name": "generate_secret", "arguments": "{}"}, + } + ], + "tool_calls", + ), + # Second call: LLM responds with text after seeing tool result + ("Here's your secret!", None, "stop"), + ] + + with patch("builtins.input", return_value="quit"): + from turnstone.bootstrap import _run_conversation + + _run_conversation(llm, tmp_path) + + # Verify two calls were made + assert llm.complete.call_count == 2 + # Verify tool result was fed back in second call's messages + second_call_messages = llm.complete.call_args_list[1][0][0] + tool_results = [m for m in second_call_messages if m.get("role") == "tool"] + assert len(tool_results) == 1 + assert tool_results[0]["tool_call_id"] == "tc_1" + # Result should be a 64-char hex string + assert len(tool_results[0]["content"]) == 64 + + def test_empty_input_skipped(self) -> None: + """Empty user input should be skipped.""" + llm = MagicMock(spec=_BootstrapLLM) + llm.complete.return_value = ("Ask me something.", None, "stop") + + call_count = 0 + + def mock_input(prompt: str = "") -> str: + nonlocal call_count + call_count += 1 + if call_count <= 2: + return "" # Empty inputs + return "quit" + + with patch("builtins.input", side_effect=mock_input): + from turnstone.bootstrap import _run_conversation + + _run_conversation(llm, Path("/tmp")) + + def test_finish_tool_exits_loop(self, tmp_path: Path) -> None: + """LLM calling finish tool should exit the conversation cleanly.""" + llm = MagicMock(spec=_BootstrapLLM) + llm.complete.return_value = ( + "", + [ + { + "id": "tc_fin", + "type": "function", + "function": { + "name": "finish", + "arguments": '{"summary": "All configured."}', + }, + } + ], + "tool_calls", + ) + + from turnstone.bootstrap import _run_conversation + + # Should return without needing user input + _run_conversation(llm, tmp_path) + assert llm.complete.call_count == 1 + + +# --------------------------------------------------------------------------- +# Interactive startup tests +# --------------------------------------------------------------------------- + + +class TestProviderDefaults: + def test_openai_default_model(self) -> None: + from turnstone.bootstrap import _DEFAULT_MODELS + + assert _DEFAULT_MODELS["openai"] == "gpt-5.4" + + def test_anthropic_default_model(self) -> None: + from turnstone.bootstrap import _DEFAULT_MODELS + + assert _DEFAULT_MODELS["anthropic"] == "claude-sonnet-4-6" + + +class TestSelectProvider: + def test_openai_selection(self) -> None: + """Selecting '1' should set up OpenAI.""" + mock_client = MagicMock() + with ( + patch("builtins.input", side_effect=["1", ""]), + patch("getpass.getpass", return_value="sk-test"), + patch("openai.OpenAI", return_value=mock_client), + ): + from turnstone.bootstrap import _select_provider + + provider, client, model = _select_provider() + assert provider == "openai" + assert model == "gpt-5.4" + + def test_local_selection(self) -> None: + """Selecting '3' should set up local/vLLM.""" + mock_client = MagicMock() + # Ensure OPENAI_API_KEY is not in env so we hit the getpass path + env = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"} + with ( + patch.dict("os.environ", env, clear=True), + patch("builtins.input", side_effect=["3", "http://localhost:8000/v1", "my-model"]), + patch("getpass.getpass", return_value="none"), + patch("openai.OpenAI", return_value=mock_client), + ): + from turnstone.bootstrap import _select_provider + + provider, client, model = _select_provider() + assert provider == "openai" + assert model == "my-model" + + +# --------------------------------------------------------------------------- +# System prompt and tools sanity checks +# --------------------------------------------------------------------------- + + +class TestConstants: + def test_system_prompt_not_empty(self) -> None: + assert len(SYSTEM_PROMPT) > 500 + + def test_system_prompt_mentions_turnstone(self) -> None: + assert "Turnstone" in SYSTEM_PROMPT + + def test_all_tools_have_required_fields(self) -> None: + for tool in TOOLS: + assert tool["type"] == "function" + func = tool["function"] + assert "name" in func + assert "description" in func + assert "parameters" in func + assert func["parameters"]["type"] == "object" + + def test_tool_count(self) -> None: + assert len(TOOLS) == 7 + + def test_all_tools_have_implementations(self) -> None: + from turnstone.bootstrap import TOOL_FUNCTIONS + + for tool in TOOLS: + name = tool["function"]["name"] + assert name in TOOL_FUNCTIONS, f"Missing implementation for tool: {name}" diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py new file mode 100644 index 00000000..aeff26b2 --- /dev/null +++ b/turnstone/bootstrap.py @@ -0,0 +1,1080 @@ +"""LLM-guided interactive setup wizard for Turnstone deployments. + +Entry point: turnstone-bootstrap + +Walks users through configuring a single-node or multi-node Turnstone +deployment via a conversational AI assistant. Generates .env files, +docker-compose overrides, and post-start setup scripts. +""" + +from __future__ import annotations + +import getpass +import json +import os +import secrets +import socket +import stat +import subprocess +import sys +from pathlib import Path +from typing import Any + +from turnstone import __version__ +from turnstone.ui.colors import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW +from turnstone.ui.markdown import MarkdownRenderer +from turnstone.ui.spinner import Spinner + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_MODELS: dict[str, str] = { + "openai": "gpt-5.4", + "anthropic": "claude-sonnet-4-6", +} + +_SENSITIVE_PATTERNS = ( + "API_KEY", + "PASSWORD", + "SECRET", + "TOKEN", + "DISCORD_TOKEN", +) + +# --------------------------------------------------------------------------- +# System prompt — encodes Turnstone architecture knowledge for the LLM +# --------------------------------------------------------------------------- + +SYSTEM_PROMPT = """\ +You are the Turnstone setup wizard, an expert assistant that helps users \ +configure a Turnstone deployment interactively. + +## About Turnstone +Turnstone is a multi-node AI orchestration platform. A deployment consists of: +- **Server** (turnstone-server): Web UI + chat workstreams + LLM interaction (port 8080) +- **Bridge** (turnstone-bridge): Redis-to-HTTP bridge for multi-node routing +- **Console** (turnstone-console): Cluster dashboard + admin panel (port 8090) +- **Redis**: Message broker, pub/sub, node registry +- **PostgreSQL** (production): Persistent database (dev can use SQLite) +- **Channel** (optional): Discord/Slack gateway + +## Deployment Profiles (compose.yaml) +- **Default**: redis + 1 server + 1 bridge + console (SQLite, good for dev/testing) +- **Production** (`--profile production`): + PostgreSQL + channel gateway (single node, production-ready) +- **Cluster** (`--profile cluster`): 10-node server/bridge fleet + PostgreSQL + channel (multi-node) + +## Environment Variables (.env) +The compose.yaml reads these from a `.env` file: + +### LLM Provider (required) +- `LLM_BASE_URL` — OpenAI-compatible API endpoint (default: http://host.docker.internal:8000/v1). \ +For local models (vLLM, llama.cpp, Ollama), this points to the local server. \ +From inside Docker, use `http://host.docker.internal:/v1` to reach the host machine. +- `OPENAI_API_KEY` — API key for the LLM provider. For local models that don't require \ +authentication, set this to `dummy` (the compose.yaml defaults to `dummy` if unset). \ +For commercial providers (OpenAI, Anthropic-via-proxy), use the real key. +- `MODEL` — Model name (optional, auto-detected if blank) +- `TAVILY_API_KEY` — Web search API key (optional) + +### Database +- `DB_BACKEND` — `sqlite` (default) or `postgresql` +- `DATABASE_URL` — PostgreSQL connection string (production/cluster only) +- `POSTGRES_USER` — PostgreSQL username (default: turnstone) +- `POSTGRES_PASSWORD` — PostgreSQL password (required for production/cluster) + +### Redis +- `REDIS_PASSWORD` — Redis password (optional but recommended) +- `REDIS_PORT` — Redis port (default: 6379) + +### Authentication +- `TURNSTONE_AUTH_ENABLED` — Enable auth (`true`/empty) +- `TURNSTONE_JWT_SECRET` — JWT signing secret (required if auth enabled) +- `TURNSTONE_AUTH_TOKEN` — Static bearer token for inter-service auth + +### Ports +- `SERVER_PORT` — Server port (default: 8080) +- `CONSOLE_PORT` — Console port (default: 8090) + +### Channel Gateway (optional) +- `TURNSTONE_DISCORD_TOKEN` — Discord bot token +- `TURNSTONE_DISCORD_GUILD` — Restrict to single guild ID + +### Cluster +- `HEARTBEAT_TTL` — Bridge heartbeat TTL in seconds (default: 60) +- `APPROVAL_TIMEOUT` — Tool approval timeout in seconds (default: 3600) + +## Auth Setup Flow +After the stack starts, the first admin user is created via: +`POST /v1/api/auth/setup` with `{"username", "display_name", "password"}` +This is a one-time endpoint that only works when zero users exist. + +Subsequent governance setup (roles, policies, templates) uses the console admin API \ +with the JWT returned from setup. + +## Built-in Roles +- **Admin** (`builtin-admin`): Full access — read, write, approve, all admin.* permissions +- **Operator** (`builtin-operator`): read, write, workstreams.create, workstreams.close +- **Viewer** (`builtin-viewer`): read only + +## Tool Policies +Glob-pattern rules for tool execution. Actions: `allow`, `deny`, `ask`. \ +First match by priority wins. Example: `{"name": "Block bash", "tool_pattern": "bash*", \ +"action": "deny", "priority": 100}` + +## Prompt Templates +Reusable system message templates with `{{variable}}` placeholders. \ +Categories like "engineering", "analysis", etc. + +## Your Task +Walk the user through setting up their deployment step by step: + +1. **First**: Call `check_docker` and `read_file` on `.env` to detect existing state. +2. **Deployment mode**: Ask if they want single-node (production) or multi-node (cluster). \ +Explain trade-offs. +3. **LLM provider for the deployment**: Which LLM backend their Turnstone will use \ +(may differ from this wizard's model). Ask for base URL, API key, model name. +4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \ +PostgreSQL is required for cluster mode. +5. **Security**: Recommend enabling auth for any non-local deployment. \ +Use `generate_secret` for JWT secret, Redis password, auth token, and Postgres password. \ +Ask for initial admin username and password. +6. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts. +7. **Optional features**: Discord integration, web search (Tavily key). +8. **Generate .env**: Call `write_file` with the complete `.env` content. +9. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \ +user and any roles/policies/templates the user wants. +10. **Finish**: Call the `finish` tool with a summary of what was configured and the \ +exact commands to run next (e.g., `docker compose --profile production up -d` then `./setup.sh`). + +## Rules +- Be concise. Ask 1-2 questions at a time, not a wall of options. +- NEVER echo API keys or passwords back to the user in your text responses. +- ALWAYS use `generate_secret` for passwords and secrets — never invent them. +- When writing files, use `write_file` — the user will see a preview and confirm. +- If an existing .env is detected, summarize what's configured and ask what to change. +- For cluster mode, the compose.yaml has a fixed 10-node fleet — no override needed. +- The `DATABASE_URL` for docker compose internal networking uses the hostname `postgres` \ +(e.g., `postgresql://turnstone:@postgres:5432/turnstone`). +- For local LLM backends (vLLM, llama.cpp, Ollama, etc.), set `OPENAI_API_KEY=dummy` in the \ +.env file — local servers typically don't require authentication. The `LLM_BASE_URL` should \ +use `host.docker.internal` to reach the host machine from inside Docker \ +(e.g., `http://host.docker.internal:8000/v1`). +- If Docker is NOT installed, tell the user they need to install it before proceeding. \ +Give them the install command for their platform: \ +Linux: `curl -fsSL https://get.docker.com | sh`, \ +macOS: "Install Docker Desktop from https://docs.docker.com/desktop/install/mac-install/", \ +Windows: "Install Docker Desktop from https://docs.docker.com/desktop/install/windows-install/". \ +You can still generate the config files — they just can't start the stack until Docker is installed. + +""" + +# --------------------------------------------------------------------------- +# Tool schemas (OpenAI function-calling format) +# --------------------------------------------------------------------------- + +TOOLS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "read_file", + "description": ( + "Read the contents of a file relative to the project directory. " + "Returns the file content or an error if the file doesn't exist." + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path relative to the project root.", + }, + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": ( + "Write content to a file. The user will be shown a preview and " + "asked to confirm before the write happens. The file is created " + "if it doesn't exist." + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path relative to the project root.", + }, + "content": { + "type": "string", + "description": "Full file content to write.", + }, + }, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "generate_secret", + "description": ( + "Generate a cryptographically secure random hex string for use " + "as JWT secrets, passwords, auth tokens, etc." + ), + "parameters": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "description": ( + "Number of random bytes. Output will be 2x this in " + "hex characters. Default: 32." + ), + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_port", + "description": "Check if a TCP port is available (not in use) on localhost.", + "parameters": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "description": "Port number to check.", + }, + }, + "required": ["port"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "validate_api_key", + "description": ( + "Test an API key by making a lightweight request to the provider. " + "Returns success/failure and any error message." + ), + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["openai", "anthropic"], + "description": "Provider name.", + }, + "api_key": { + "type": "string", + "description": "API key to validate.", + }, + "base_url": { + "type": "string", + "description": ( + "API base URL. Only needed for OpenAI-compatible endpoints." + ), + }, + }, + "required": ["provider", "api_key"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_docker", + "description": ( + "Check if Docker and Docker Compose are installed and the Docker " + "daemon is running. Returns version info or error details." + ), + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "finish", + "description": ( + "Call this tool when the bootstrap setup is complete and all files " + "have been written. Displays a final summary and exits the wizard. " + "You MUST call this after writing all config files and printing " + "the next-steps summary." + ), + "parameters": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": ( + "A short summary of what was configured (deployment mode, " + "files written, next commands to run)." + ), + }, + }, + "required": ["summary"], + }, + }, + }, +] + +# --------------------------------------------------------------------------- +# Tool implementations +# --------------------------------------------------------------------------- + + +def _mask_secrets(text: str) -> str: + """Mask sensitive values in text for display preview.""" + lines = text.split("\n") + masked: list[str] = [] + for line in lines: + if "=" in line and not line.lstrip().startswith("#"): + key, _, value = line.partition("=") + key_upper = key.strip().upper() + if any(pat in key_upper for pat in _SENSITIVE_PATTERNS) and len(value) > 8: + masked.append(f"{key}={value[:4]}****{value[-4:]}") + continue + masked.append(line) + return "\n".join(masked) + + +def _resolve_safe(project_dir: Path, raw_path: str) -> Path | None: + """Resolve a path and verify it stays within project_dir. Returns None if unsafe.""" + resolved = (project_dir / raw_path).resolve() + if not resolved.is_relative_to(project_dir.resolve()): + return None + return resolved + + +def _tool_read_file(project_dir: Path, args: dict[str, Any]) -> str: + """Read a file relative to the project directory.""" + raw = str(args["path"]) + path = _resolve_safe(project_dir, raw) + if path is None: + return f"Error: path escapes project directory: {raw}" + try: + content: str = path.read_text(encoding="utf-8") + return content + except FileNotFoundError: + return f"Error: file not found: {args['path']}" + except (OSError, UnicodeDecodeError) as exc: + return f"Error reading {args['path']}: {exc}" + + +def _tool_write_file(project_dir: Path, args: dict[str, Any]) -> str: + """Write a file with user confirmation.""" + raw = str(args["path"]) + path = _resolve_safe(project_dir, raw) + if path is None: + return f"Error: path escapes project directory: {raw}" + content = args["content"] + + # Skip if file already exists with identical content + if path.exists(): + try: + existing = path.read_text(encoding="utf-8") + if existing == content: + return f"File already exists with identical content: {args['path']}" + except (OSError, UnicodeDecodeError): + pass + + line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0) + + # Show preview + print(f"\n{YELLOW} Writing {args['path']} ({line_count} lines){RESET}") + print(f"{DIM}{'─' * 50}{RESET}") + preview = _mask_secrets(content) + for line in preview.split("\n")[:50]: + print(f" {DIM}{line}{RESET}") + if line_count > 50: + print(f" {DIM}... ({line_count - 50} more lines){RESET}") + print(f"{DIM}{'─' * 50}{RESET}") + + try: + choice = input(f"{BOLD}Write this file? [Y/n]{RESET} ").strip().lower() + except (EOFError, KeyboardInterrupt): + return "User cancelled the write." + if choice in ("n", "no"): + return "User declined to write file." + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + # Make .sh files executable + if path.suffix == ".sh": + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) + + return f"File written successfully: {args['path']}" + + +def _tool_generate_secret(args: dict[str, Any]) -> str: + """Generate a cryptographically secure random hex string.""" + length = args.get("length", 32) + if not isinstance(length, int) or length < 1 or length > 128: + length = 32 + return secrets.token_hex(length) + + +def _tool_check_port(args: dict[str, Any]) -> str: + """Check if a TCP port is available on localhost.""" + port = args["port"] + if not isinstance(port, int) or port < 1 or port > 65535: + return f"Error: invalid port number: {port}" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + result = sock.connect_ex(("127.0.0.1", port)) + if result == 0: + return f"Port {port} is IN USE (something is already listening)." + return f"Port {port} is AVAILABLE." + except OSError as exc: + return f"Error checking port {port}: {exc}" + + +def _tool_validate_api_key(args: dict[str, Any]) -> str: + """Validate an API key with a lightweight request.""" + provider = args["provider"] + api_key = args["api_key"] + base_url = args.get("base_url") + + if provider == "openai": + try: + from openai import OpenAI + + kwargs: dict[str, Any] = {"api_key": api_key} + if base_url: + kwargs["base_url"] = base_url + oai_client = OpenAI(**kwargs) + oai_client.models.list() + return "Success: API key is valid." + except Exception as exc: + return f"Failed: {exc}" + + elif provider == "anthropic": + try: + import anthropic + + ant_client = anthropic.Anthropic(api_key=api_key) + ant_client.messages.create( + model="claude-sonnet-4-6", + max_tokens=1, + messages=[{"role": "user", "content": "hi"}], + ) + return "Success: API key is valid." + except Exception as exc: + return f"Failed: {exc}" + + return f"Error: unknown provider '{provider}'" + + +def _tool_check_docker(args: dict[str, Any]) -> str: + """Check Docker and Docker Compose availability.""" + results: list[str] = [] + + # Check Docker + try: + proc = subprocess.run( + ["docker", "version", "--format", "{{.Server.Version}}"], + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode == 0: + results.append(f"Docker: installed (version {proc.stdout.strip()})") + else: + stderr = proc.stderr.strip() + if "Cannot connect" in stderr or "Is the docker daemon running" in stderr: + results.append("Docker: installed but daemon is NOT running") + else: + results.append(f"Docker: error — {stderr}") + except FileNotFoundError: + results.append("Docker: NOT installed") + except subprocess.TimeoutExpired: + results.append("Docker: timed out (daemon may be unresponsive)") + + # Check Docker Compose + try: + proc = subprocess.run( + ["docker", "compose", "version", "--short"], + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode == 0: + results.append(f"Docker Compose: installed (version {proc.stdout.strip()})") + else: + results.append("Docker Compose: NOT available") + except (FileNotFoundError, subprocess.TimeoutExpired): + results.append("Docker Compose: NOT available") + + return "\n".join(results) + + +class _FinishError(Exception): + """Raised by the finish tool to signal the wizard is done.""" + + def __init__(self, summary: str) -> None: + self.summary = summary + + +def _tool_finish(args: dict[str, Any]) -> str: + """Signal that the bootstrap wizard is complete.""" + raise _FinishError(args.get("summary", "Setup complete.")) + + +# Tool dispatch table +TOOL_FUNCTIONS: dict[str, Any] = { + "read_file": _tool_read_file, + "write_file": _tool_write_file, + "generate_secret": _tool_generate_secret, + "check_port": _tool_check_port, + "validate_api_key": _tool_validate_api_key, + "check_docker": _tool_check_docker, + "finish": _tool_finish, +} + +# Tools that need the project_dir argument +_PROJECT_DIR_TOOLS = frozenset({"read_file", "write_file"}) + + +def execute_tool(name: str, args: dict[str, Any], project_dir: Path) -> str: + """Execute a tool and return the result string. + + Raises _FinishError when the finish tool is called. + """ + fn = TOOL_FUNCTIONS.get(name) + if fn is None: + return f"Error: unknown tool '{name}'" + try: + if name in _PROJECT_DIR_TOOLS: + result: str = fn(project_dir, args) + else: + result = fn(args) + return result + except _FinishError: + raise + except Exception as exc: + return f"Error executing {name}: {exc}" + + +# --------------------------------------------------------------------------- +# _BootstrapLLM — thin wrapper over OpenAI / Anthropic SDKs +# --------------------------------------------------------------------------- + + +class _BootstrapLLM: + """Provider-agnostic wrapper for non-streaming tool-calling completions.""" + + def __init__(self, provider: str, client: Any, model: str) -> None: + self.provider = provider + self.client = client + self.model = model + + def complete( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, Any]] | None, str]: + """Run a completion and return (content, tool_calls, stop_reason).""" + if self.provider == "anthropic": + return self._complete_anthropic(messages, tools) + return self._complete_openai(messages, tools) + + # -- OpenAI path -------------------------------------------------------- + + def _complete_openai( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, Any]] | None, str]: + resp = self.client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools if tools else None, + ) + choice = resp.choices[0] + content = choice.message.content or "" + tool_calls = None + if choice.message.tool_calls: + tool_calls = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in choice.message.tool_calls + ] + return content, tool_calls, choice.finish_reason or "stop" + + # -- Anthropic path ----------------------------------------------------- + + def _complete_anthropic( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, Any]] | None, str]: + # Extract system message + system_text = "" + api_messages: list[dict[str, Any]] = [] + for msg in messages: + if msg["role"] == "system": + system_text = msg["content"] + elif msg["role"] == "tool": + # Convert OpenAI tool result to Anthropic format + api_messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": msg["tool_call_id"], + "content": msg["content"], + } + ], + } + ) + elif msg["role"] == "assistant" and msg.get("tool_calls"): + # Convert assistant tool_calls to Anthropic content blocks + blocks: list[dict[str, Any]] = [] + if msg.get("content"): + blocks.append({"type": "text", "text": msg["content"]}) + for tc in msg["tool_calls"]: + blocks.append( + { + "type": "tool_use", + "id": tc["id"], + "name": tc["function"]["name"], + "input": json.loads(tc["function"]["arguments"]), + } + ) + api_messages.append({"role": "assistant", "content": blocks}) + else: + api_messages.append(msg) + + # Merge consecutive same-role messages (Anthropic requires alternation) + merged: list[dict[str, Any]] = [] + for msg in api_messages: + if merged and merged[-1]["role"] == msg["role"]: + # Merge content + prev = merged[-1] + prev_content = prev["content"] + new_content = msg["content"] + if isinstance(prev_content, str) and isinstance(new_content, str): + prev["content"] = prev_content + "\n" + new_content + elif isinstance(prev_content, str): + prev["content"] = [{"type": "text", "text": prev_content}] + ( + new_content if isinstance(new_content, list) else [new_content] + ) + elif isinstance(new_content, str): + prev["content"] = prev_content + [{"type": "text", "text": new_content}] + else: + prev["content"] = prev_content + new_content + else: + merged.append(msg) + api_messages = merged + + # Convert tools + api_tools = [ + { + "name": t["function"]["name"], + "description": t["function"]["description"], + "input_schema": t["function"]["parameters"], + } + for t in tools + ] + + resp = self.client.messages.create( + model=self.model, + max_tokens=4096, + system=system_text, + messages=api_messages, + tools=api_tools if api_tools else [], + ) + + # Parse response + content_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + for block in resp.content: + if block.type == "text": + content_parts.append(block.text) + elif block.type == "tool_use": + tool_calls.append( + { + "id": block.id, + "type": "function", + "function": { + "name": block.name, + "arguments": json.dumps(block.input), + }, + } + ) + + content = "\n".join(content_parts) + return ( + content, + tool_calls if tool_calls else None, + resp.stop_reason or "end_turn", + ) + + +# --------------------------------------------------------------------------- +# Interactive startup (Phase 1: before LLM) +# --------------------------------------------------------------------------- + + +def _print_banner() -> None: + print(f"\n{BOLD}{CYAN} Turnstone Bootstrap Wizard{RESET} {DIM}v{__version__}{RESET}") + print(f" {DIM}{'─' * 48}{RESET}") + print() + print(" This wizard uses an AI model to walk you through") + print(" setting up a Turnstone deployment. You'll need an") + print(" API key for one of the supported providers.") + print() + + +def _select_provider() -> tuple[str, Any, str]: + """Interactive provider/model/key selection. Returns (provider, client, model).""" + print(f" {BOLD}Which provider for this wizard?{RESET}") + print(f" {CYAN}[1]{RESET} OpenAI") + print(f" {CYAN}[2]{RESET} Anthropic") + print(f" {CYAN}[3]{RESET} OpenAI-compatible (local/vLLM)") + print() + + while True: + try: + choice = input(f" {BOLD}>{RESET} ").strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if choice in ("1", "2", "3"): + break + print(f" {RED}Please enter 1, 2, or 3.{RESET}") + + if choice == "1": + return _setup_openai() + elif choice == "2": + return _setup_anthropic() + else: + return _setup_local() + + +def _prompt_api_key(env_var: str, label: str) -> str: + """Prompt for an API key, checking env var first.""" + env_val = os.environ.get(env_var, "") + if env_val: + prefix = env_val[:4] + "..." if len(env_val) > 4 else env_val + print(f"\n Found {CYAN}${env_var}{RESET} in environment ({DIM}{prefix}{RESET})") + try: + use_env = input(f" Use it? {BOLD}[Y/n]{RESET} ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if use_env not in ("n", "no"): + return env_val + + print(f"\n {label}") + try: + key = getpass.getpass(" API key: ") + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if not key.strip(): + print(f" {RED}API key cannot be empty.{RESET}") + sys.exit(1) + return key.strip() + + +def _prompt_model(provider: str) -> str: + """Prompt for model name with a sensible default.""" + default = _DEFAULT_MODELS.get(provider, "") + prompt = f" Model {DIM}[{default}]{RESET}: " if default else " Model name: " + try: + model = input(prompt).strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + return model or default + + +def _setup_openai() -> tuple[str, Any, str]: + from openai import OpenAI + + api_key = _prompt_api_key("OPENAI_API_KEY", "Enter your OpenAI API key:") + model = _prompt_model("openai") + client = OpenAI(api_key=api_key) + return "openai", client, model + + +def _setup_anthropic() -> tuple[str, Any, str]: + try: + import anthropic + except ImportError: + print(f"\n {RED}The 'anthropic' package is not installed.{RESET}") + print(f" Install it with: {CYAN}pip install turnstone[anthropic]{RESET}") + sys.exit(1) + + api_key = _prompt_api_key("ANTHROPIC_API_KEY", "Enter your Anthropic API key:") + model = _prompt_model("anthropic") + client = anthropic.Anthropic(api_key=api_key) + return "anthropic", client, model + + +def _detect_models(client: Any) -> list[str]: + """Query /v1/models and return a sorted list of model IDs.""" + try: + resp = client.models.list() + models = sorted(m.id for m in resp.data) + return models + except Exception: + return [] + + +def _setup_local() -> tuple[str, Any, str]: + from openai import OpenAI + + print("\n Enter the base URL of your OpenAI-compatible endpoint.") + default_url = "http://localhost:8000/v1" + try: + url = input(f" Base URL {DIM}[{default_url}]{RESET}: ").strip() or default_url + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + + # Local endpoints often don't need a real key + env_key = os.environ.get("OPENAI_API_KEY", "") + if env_key: + api_key = env_key + print(f" Using {CYAN}$OPENAI_API_KEY{RESET} from environment.") + else: + print(" API key (press Enter for 'none'):") + try: + api_key = getpass.getpass(" API key: ") or "none" + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + + client = OpenAI(api_key=api_key, base_url=url) + + # Try to auto-detect available models + print(f"\n {DIM}Querying {url} for available models...{RESET}") + available = _detect_models(client) + + if len(available) == 1: + model = available[0] + print(f" Found model: {CYAN}{model}{RESET}") + elif available: + print(f" Found {len(available)} model(s):") + for i, m in enumerate(available, 1): + print(f" {CYAN}[{i}]{RESET} {m}") + print() + try: + choice = input(f" Select model {DIM}[1]{RESET}: ").strip() or "1" + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + try: + idx = int(choice) - 1 + model = available[idx] if 0 <= idx < len(available) else choice + except ValueError: + model = choice # Treat as literal model name + else: + print(f" {YELLOW}Could not auto-detect models.{RESET}") + try: + model = input(" Model name: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nCancelled.") + sys.exit(0) + if not model: + print(f" {RED}Model name is required for local endpoints.{RESET}") + sys.exit(1) + + return "openai", client, model + + +def _validate_connection(llm: _BootstrapLLM) -> bool: + """Validate the LLM connection with a minimal request.""" + try: + content, _, _ = llm.complete( + [ + {"role": "system", "content": "Reply with exactly: ok"}, + {"role": "user", "content": "ping"}, + ], + [], + ) + return True + except Exception as exc: + print(f"\n {RED}Connection failed: {exc}{RESET}") + return False + + +# --------------------------------------------------------------------------- +# Conversation loop +# --------------------------------------------------------------------------- + + +def _run_conversation( + llm: _BootstrapLLM, + project_dir: Path, +) -> None: + """Main LLM-driven conversation loop.""" + renderer = MarkdownRenderer() + + messages: list[dict[str, Any]] = [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + "I'd like to set up Turnstone. Please start by checking if " + "Docker is available and if there's an existing .env configuration." + ), + }, + ] + + _max_retries = 3 + retries = 0 + + while True: + # Get LLM response + with Spinner("Thinking"): + try: + content, tool_calls, reason = llm.complete(messages, TOOLS) + except KeyboardInterrupt: + print(f"\n{DIM}(Interrupted. Type 'quit' to exit.){RESET}") + messages.append( + {"role": "user", "content": "The user interrupted. Ask what they need."} + ) + continue + except Exception as exc: + retries += 1 + if retries >= _max_retries: + print(f"\n{RED}LLM error after {_max_retries} attempts: {exc}{RESET}") + print("Please check your connection and try again.") + return + print(f"\n{RED}LLM error: {exc}{RESET}") + print(f"{DIM}Retrying ({retries}/{_max_retries})...{RESET}") + continue + + retries = 0 # Reset on success + + # Build assistant message + assistant_msg: dict[str, Any] = {"role": "assistant", "content": content or ""} + if tool_calls: + assistant_msg["tool_calls"] = tool_calls + messages.append(assistant_msg) + + # Print text content + if content: + rendered = renderer.feed(content + "\n") + flushed = renderer.flush() + print(rendered + flushed, end="") + + # Execute tool calls + if tool_calls: + for tc in tool_calls: + name = tc["function"]["name"] + try: + args = json.loads(tc["function"]["arguments"]) + except json.JSONDecodeError as exc: + result = f"Error: invalid JSON arguments: {exc}" + args = {} + else: + print(f" {DIM}[{name}]{RESET}", end="") + if name in ("read_file", "write_file") and "path" in args: + print(f" {DIM}{args['path']}{RESET}") + elif name == "check_port" and "port" in args: + print(f" {DIM}:{args['port']}{RESET}") + else: + print() + try: + result = execute_tool(name, args, project_dir) + except _FinishError as fin: + print(f"\n{GREEN}{BOLD} Setup complete!{RESET}\n") + rendered = renderer.feed(fin.summary + "\n") + flushed = renderer.flush() + print(rendered + flushed, end="") + return + + messages.append( + { + "role": "tool", + "tool_call_id": tc["id"], + "content": result, + } + ) + continue # Let LLM process tool results + + # No tool calls — prompt user + print() + try: + user_input = input(f"{BOLD}>{RESET} ").strip() + except EOFError: + print("\nGoodbye!") + return + except KeyboardInterrupt: + print(f"\n{DIM}(Press Ctrl+C again to quit, or type your response.){RESET}") + try: + user_input = input(f"{BOLD}>{RESET} ").strip() + except (EOFError, KeyboardInterrupt): + print("\nGoodbye!") + return + + if user_input.lower() in ("quit", "exit", "q"): + print("Goodbye!") + return + + if not user_input: + continue + + messages.append({"role": "user", "content": user_input}) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """Entry point for turnstone-bootstrap CLI.""" + _print_banner() + + # Phase 1: Interactive provider selection + provider, client, model = _select_provider() + llm = _BootstrapLLM(provider, client, model) + + # Validate connection + print(f"\n {DIM}Validating connection...{RESET}") + if not _validate_connection(llm): + print(f" {RED}Could not connect to the model. Please check your settings.{RESET}") + sys.exit(1) + + print(f"\n {GREEN}Connected to {BOLD}{model}{RESET}{GREEN}.{RESET}") + print(f" {DIM}Handing off to AI assistant...{RESET}\n") + + # Phase 2: LLM-driven conversation + project_dir = Path.cwd() + try: + _run_conversation(llm, project_dir) + except KeyboardInterrupt: + print("\nGoodbye!") + sys.exit(0) + + +if __name__ == "__main__": + main()